init
No files matched your search
@@ -0,0 +1,3 @@
|
||||
/build
|
||||
/output/imgs
|
||||
/scripts/__pycache__
|
||||
@@ -0,0 +1,82 @@
|
||||
# AGENTS.md
|
||||
|
||||
## 项目目标
|
||||
|
||||
本仓库要构建一个**离线、以物理正确性为优先的数值相对论时空视频渲染器**。最终目标是把双黑洞(BBH)并合等动态数值相对论(NR)演化输出的真实四维时空渲染为 4K 视频。
|
||||
|
||||
渲染器必须直接消费时间依赖的 4D metric,对每条相机光线进行向过去的 null geodesic tracing;它不是仅渲染静态快照、波形或标量诊断的工具。第一阶段只处理两种 ray 终态:被黑洞捕获,或逃逸到无穷远天球。局域物质辐射、吸积盘、流体和等离子体均不在当前范围内。
|
||||
|
||||
设计依据是 [`nr_spacetime_movie_renderer_design.md`](nr_spacetime_movie_renderer_design.md)。实现前应先阅读与修改内容相关的章节;该文档是架构和物理取舍的权威来源。
|
||||
|
||||
## 不要偏离的方向
|
||||
|
||||
- 不以实时性能、游戏式视觉近似或 ShaderToy 式效果为目标。
|
||||
- frozen snapshot 只能作为数值演化代码内的廉价诊断,不能替代最终的动态 4D 渲染。
|
||||
- 不把恒星背景预烘焙成 RGB 天球纹理;恒星是带方向、温度和振幅的点源 catalog。
|
||||
- 相机不是固定三维坐标,而是 worldline 加 tetrad 的预生成轨迹。
|
||||
- 第一版只做 CPU;先验证物理与数据流,再考虑 GPU。
|
||||
- 不为复杂 OO/class hierarchy 增加抽象。首选 C 的 `struct`、明确 ownership、函数表和连续数组。
|
||||
|
||||
## 核心架构约束
|
||||
|
||||
### 时间组织
|
||||
|
||||
所有帧的 ray 必须按 coordinate time 汇总,沿时间从新到旧,通过可装入内存的 metric time slab 推进。不要按 frame 各自重复加载所需的 4D 数据。
|
||||
|
||||
每个 refinement pass 只追踪新请求的 samples:完整追踪后才能判断 image-plane triangle 是否需要细分,而早期 slab 此时已释放。不能在同一次 slab sweep 中临时创建新 ray 并回到相机时间重新追踪。
|
||||
|
||||
### Ray 与并行
|
||||
|
||||
- 以 coordinate time `t` 作为 geodesic ODE 自变量,使 ray 演化与 slab 边界自然对齐。
|
||||
- 批量 ray 状态采用 SoA `RayPool`,避免 per-ray allocation;支持 activation、终止和 stream compaction。
|
||||
- 用 OpenMP 对 active ray pool 做粗粒度循环并行。不要为单条 ray 或 triangle 创建 task,也不要引入细粒度 mutex。
|
||||
- metric evaluator 的可变缓存和插值 scratch space 必须是 thread-local,不能共享可变 cache。
|
||||
- 优先 bulk arrays、顺序 metric I/O、time-slab streaming 和 coarse-grained parallelism。
|
||||
|
||||
### 镜头映射与点源渲染
|
||||
|
||||
每帧在 image plane 上使用 adaptive triangle mesh。每个顶点保存 film 坐标、ray 状态、逃逸方向 `n_inf` 与频移信息。根据真实 mapping 与局部插值的误差、orientation consistency 和 Jacobian 奇异性进行局部细分。
|
||||
|
||||
最终从局部可逆 triangle 构造 inverse lens map:`sky direction -> image position`。同一恒星被多个局部 patch 覆盖时自然形成多像;不要依赖全局“第几阶像”分类。
|
||||
|
||||
catalog 内部数据保留 `(direction, temperature, amplitude)`,而非 RGB。对每个像计算局部 magnification 与频率比 `g`,用 `T_obs = g * T_emit` 处理黑体频移,并以保留亚像素位置的 PSF 直接 splat 到 HDR framebuffer。
|
||||
|
||||
## Backend 与模块边界
|
||||
|
||||
顶层 movie scheduler 不得依赖具体 metric 来源。时空 backend 至少应可替换为:Minkowski、解析 Schwarzschild,以及 nmesh 4D 数值时空;未来可加入 Kerr/BBH。
|
||||
|
||||
- `observer`:worldline/tetrad 读取、插值和正交化;不实现 geodesic 或渲染。
|
||||
- `movie` / `frame`:帧时间表、adaptive mesh、refinement 请求与 ray 结果回填。
|
||||
- `ray`:批量 ray 状态、初始化、生命周期和 compaction。
|
||||
- `geodesic`:3+1 null geodesic RHS、频移、ODE stepper 和 slab 内推进;不处理磁盘 I/O 或 frame mesh。
|
||||
- `spacetime`:backend 统一接口、slab、metric/导数插值以及 capture/infinity classification。
|
||||
- `nmesh_backend`:读取原生 DG node 输出、AMR lookup、Lagrange 空间插值及导数、时间插值和 puncture trajectories。
|
||||
- `catalog`:Gaia/2MASS 等 catalog 的读取、内部转换和天球索引。
|
||||
- `optics` / `psf`:黑体频移、linear RGB、flux、PSF 和 HDR 累积。
|
||||
- `output`:HDR、曝光、tone mapping、PNG/EXR 与视频编码接口。
|
||||
|
||||
对 nmesh 数据,直接在原生 DG nodes 上用 Lagrange basis 插值及求空间导数;不要先重采样到 Cartesian uniform grid,也不要求相邻时间 slice 的 AMR tree 拓扑对应。对固定全局点,应分别在各 slice 的空间 mesh 上求值,再进行时间插值。
|
||||
|
||||
优先从 3+1 identities、已知 gauge RHS 或 temporal interpolant 的解析导数获得时间导数;不要为已有插值量另行做低阶 finite difference,也不要在 geodesic RHS 中计算不会使用的量。
|
||||
|
||||
## 黑洞终止
|
||||
|
||||
对 moving-puncture 数据,生产渲染使用经 AH calibration 得到、保守地位于 apparent horizon 内部的 puncture-centered cutoff 判定捕获。不要假定每次生产演化都会运行昂贵的 AH finder。可在未来加入 common-horizon 终止优化,但不得改变物理分类。
|
||||
|
||||
## 开发与验证顺序
|
||||
|
||||
按以下层级实现和回归验证,后续阶段不得绕过前一阶段的基准:
|
||||
|
||||
1. Minkowski + 星表:验证 observer/camera projection、天球方向、catalog、温度/振幅、PSF 和 mesh。
|
||||
2. 解析 Schwarzschild:验证 capture、Einstein ring、多像、refinement、局部 inverse map、放大率和频移。
|
||||
3. 数值单 Schwarzschild:验证动态 4D slab streaming,并与解析解比较逃逸方向、频移和捕获分类。
|
||||
4. FOZ4c BBH:在上层架构不重设计的前提下接入成熟的 BBH 演化。
|
||||
|
||||
新增物理、插值或优化时,优先添加能与前一阶段比较的收敛测试或 regression test。尚未由 prototype/convergence test 决定的参数(如时间输出 cadence、时间插值阶数、slab 大小、refinement 阈值、PSF、ODE stepper)不要伪装成既定事实。
|
||||
|
||||
## 修改原则
|
||||
|
||||
- 改动应保持 backend、observer、geodesic、movie mesh 与 optics 的职责分离。
|
||||
- 性能优化不得破坏 time-dependent tracing、局部可逆映射或点源的亚像素 PSF 渲染。
|
||||
- 新增缓存先明确 ownership、生命周期和线程归属;高开销可变缓存默认 thread-local。
|
||||
- 未在设计文档中明确的物理或数值取舍,应通过小型 prototype、基准或收敛实验确定,并同步更新设计文档。
|
||||
@@ -0,0 +1,64 @@
|
||||
CC ?= cc
|
||||
CFLAGS ?= -std=c11 -O2 -Wall -Wextra -Wpedantic
|
||||
OPENMP_FLAGS ?= -fopenmp
|
||||
LDLIBS ?= -lm
|
||||
ENABLE_PNG ?= 0
|
||||
SPACETIME ?= minkowski
|
||||
|
||||
ifeq ($(ENABLE_PNG),1)
|
||||
CPPFLAGS += -DENABLE_PNG
|
||||
LDLIBS += -lpng
|
||||
endif
|
||||
|
||||
COMMON_SOURCES := $(filter-out src/main.c src/spacetime_minkowski.c src/spacetime_schwarzschild.c,$(wildcard src/*.c))
|
||||
PROVIDER_SOURCE := src/spacetime_$(SPACETIME).c
|
||||
TARGET := build/$(SPACETIME)_sky
|
||||
CORE_MINKOWSKI_SOURCES := $(COMMON_SOURCES) src/spacetime_minkowski.c
|
||||
TEST_TARGET := build/test_geodesic
|
||||
FRAME_TEST_TARGET := build/test_frame
|
||||
SCHWARZSCHILD_TEST_TARGET := build/test_schwarzschild
|
||||
|
||||
.PHONY: all clean run test minkowski schwarzschild
|
||||
|
||||
ifeq ($(SPACETIME),minkowski)
|
||||
BACKEND_CPPFLAGS := -DSPACETIME_MINKOWSKI
|
||||
else ifeq ($(SPACETIME),schwarzschild)
|
||||
BACKEND_CPPFLAGS := -DSPACETIME_SCHWARZSCHILD
|
||||
else
|
||||
$(error Unknown SPACETIME '$(SPACETIME)'; choose minkowski or schwarzschild)
|
||||
endif
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(COMMON_SOURCES) $(PROVIDER_SOURCE) src/main.c | build
|
||||
$(CC) $(CPPFLAGS) $(BACKEND_CPPFLAGS) $(CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
|
||||
|
||||
build:
|
||||
mkdir -p $@
|
||||
|
||||
run: $(TARGET)
|
||||
mkdir -p output/imgs
|
||||
./$(TARGET) --catalog assets/sky_grid_5deg.csv --output output/imgs/$(SPACETIME)_sky.ppm
|
||||
|
||||
minkowski:
|
||||
$(MAKE) SPACETIME=minkowski all
|
||||
|
||||
schwarzschild:
|
||||
$(MAKE) SPACETIME=schwarzschild all
|
||||
|
||||
$(TEST_TARGET): tests/test_geodesic.c $(CORE_MINKOWSKI_SOURCES) | build
|
||||
$(CC) $(CPPFLAGS) $(CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
|
||||
|
||||
$(FRAME_TEST_TARGET): tests/test_frame.c $(CORE_MINKOWSKI_SOURCES) | build
|
||||
$(CC) $(CPPFLAGS) $(CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
|
||||
|
||||
$(SCHWARZSCHILD_TEST_TARGET): tests/test_schwarzschild.c $(COMMON_SOURCES) src/spacetime_schwarzschild.c | build
|
||||
$(CC) $(CPPFLAGS) $(CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@
|
||||
|
||||
test: $(TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET)
|
||||
./$(TEST_TARGET)
|
||||
./$(FRAME_TEST_TARGET)
|
||||
./$(SCHWARZSCHILD_TEST_TARGET)
|
||||
|
||||
clean:
|
||||
rm -rf build
|
||||
@@ -0,0 +1,125 @@
|
||||
# GR 4D ray tracing — Phase 0 prototype
|
||||
|
||||
`minkowski_sky` is a deliberately small, CPU-only, single-frame Phase 0
|
||||
benchmark. It renders point sources from a sky catalog through an analytic
|
||||
backend. It is not a sky texture: each source remains a direction, temperature,
|
||||
and amplitude until its sub-pixel Gaussian PSF is splatted.
|
||||
|
||||
Build and render the default 1280 x 720 image:
|
||||
|
||||
```sh
|
||||
make run
|
||||
```
|
||||
|
||||
The program first creates `assets/sky_grid_5deg.csv` when it is missing. The
|
||||
synthetic catalog places stars every 2 degrees on the union of longitude and
|
||||
latitude lines spaced 10 degrees apart; the two poles are stored only once.
|
||||
The eight octants (four 90-degree longitude sectors in each hemisphere)
|
||||
alternate red `temperature_K = 3000` and blue `temperature_K = 12000`.
|
||||
Red stars use `amplitude = 1`; blue stars use `amplitude = 0.00141095580387`,
|
||||
which equalizes their CIE/linear-sRGB luminance under the renderer's blackbody
|
||||
integration. Longitude boundaries belong to the sector to their east and the
|
||||
equator to the northern hemisphere, so boundary stars have a deterministic
|
||||
color.
|
||||
The output is a binary PPM at `output/imgs/minkowski_sky.ppm`; it can be inspected by
|
||||
most image viewers or converted to PNG with ImageMagick.
|
||||
|
||||
PNG output is optional so the default build has no `libpng` dependency. Build
|
||||
with `make ENABLE_PNG=1`, then select it with a `.png` output path:
|
||||
|
||||
```sh
|
||||
make clean && make ENABLE_PNG=1
|
||||
mkdir -p output/imgs
|
||||
./build/minkowski_sky --output output/imgs/minkowski_sky.png
|
||||
```
|
||||
|
||||
Run the flat-spacetime geodesic regression with:
|
||||
|
||||
```sh
|
||||
make test
|
||||
```
|
||||
|
||||
This also checks that the Kerr--Schild metric remains finite at `r=2M` and
|
||||
that the central ray from the default Schwarzschild camera is classified as
|
||||
captured.
|
||||
|
||||
Build an independent analytic Schwarzschild executable in Cartesian ingoing
|
||||
Kerr--Schild coordinates (regular at the horizon), then render the test catalog
|
||||
to PNG:
|
||||
|
||||
```sh
|
||||
make clean && make SPACETIME=schwarzschild ENABLE_PNG=1
|
||||
mkdir -p output/imgs
|
||||
./build/schwarzschild_sky --catalog assets/sky_grid_5deg.csv \
|
||||
--width 640 --height 360 --coarse-cell-pixels 8 --fov-deg 60 \
|
||||
--output output/imgs/schwarzschild_test_catalog.png
|
||||
```
|
||||
|
||||
`SPACETIME=minkowski` (the default) and `SPACETIME=schwarzschild` select source
|
||||
files at compile time, so each executable contains exactly one metric provider.
|
||||
The Schwarzschild demonstration uses mass `M=1`, a static camera at Cartesian
|
||||
Kerr--Schild position `(30, 0, 0)`, directed at the hole, escapes at `r=256`,
|
||||
and declares capture at `r=1.5`, safely inside the horizon at `r=2`. Those
|
||||
rendering thresholds are Phase-1 demonstration values, not settled production
|
||||
refinement or integration settings.
|
||||
|
||||
For a local radial boost relative to that static camera, pass
|
||||
`--observer-inward-speed V`, where `0 <= V < 1` is measured in the static
|
||||
observer's orthonormal frame and positive values point toward the hole. The
|
||||
default is `0`, preserving the static camera.
|
||||
|
||||
For rays that asymptote to the future horizon in coordinate-time backward
|
||||
integration, the Schwarzschild demo also terminates at
|
||||
`log(alpha p^0) = 8`. This is the normalized-momentum horizon diagnostic
|
||||
already evolved by the integrator; it is disabled by default and does not
|
||||
replace the AH-calibrated spatial capture criterion planned for nmesh data.
|
||||
|
||||
Useful options:
|
||||
|
||||
```sh
|
||||
./build/minkowski_sky --width 1920 --height 1080 --fov-deg 30 \
|
||||
--catalog assets/sky_grid_5deg.csv --output output/imgs/frame.ppm
|
||||
./build/minkowski_sky --catalog assets/2mass/processed/2mass_psc_m31_0p5deg_stars.csv \
|
||||
--look-ra-deg 10.6847083 --look-dec-deg 41.26875 --fov-deg 1.8 \
|
||||
--exposure 1e15 --output output/imgs/2mass_m31.ppm
|
||||
./build/minkowski_sky --catalog assets/2mass/processed/2mass_psc_m44_1p0deg_stars.csv \
|
||||
--look-ra-deg 129.99165 --look-dec-deg 19.54139 --fov-deg 2.0 \
|
||||
--exposure 1e15 --width 1920 --height 1920 --output output/imgs/2mass_m44.ppm
|
||||
./build/minkowski_sky --write-catalog assets/sky_grid_5deg.csv
|
||||
```
|
||||
|
||||
The camera is a fixed inertial observer at coordinate position `(0,0,0)`,
|
||||
with a tetrad whose forward direction is coordinate `-Z` and whose vertical
|
||||
direction is `+Y`. The frame first triangulates the image plane, then traces
|
||||
only its vertices backwards. Escaped endpoints form a triangulation on the
|
||||
source sky. For every locally invertible triangle, catalog stars inside its
|
||||
spherical source triangle are interpolated back to the image triangle and
|
||||
splatted as PSFs. Consequently multiple image triangles naturally create
|
||||
multiple images of the same star.
|
||||
|
||||
The ray state evolves `(x^i, Pi_i, log(alpha p^0))` in coordinate time with
|
||||
RK4 using the 3+1 equations in Bohn et al. II.A, until the spacetime backend
|
||||
classifies the ray. `spacetime.c` is the only module containing the Minkowski
|
||||
metric or its infinity criterion; frame, observer, and integrator use only
|
||||
`SpacetimeSource` and `MetricData`. The initial regular mesh size is exposed
|
||||
as `--coarse-cell-pixels`; it is a Phase-0 sampling knob, not a settled
|
||||
production refinement threshold.
|
||||
|
||||
`--look-ra-deg` and `--look-dec-deg` rotate that fixed tetrad so its forward
|
||||
axis is the corresponding catalog direction; their defaults reproduce the
|
||||
original `-Z` view. `--exposure` converts a catalog's physical flux
|
||||
normalization to the prototype HDR scale. Its default preserves the synthetic
|
||||
catalog benchmark; a 2MASS blackbody normalization in steradians requires a
|
||||
much larger display exposure such as the example above. The optics path
|
||||
integrates each fitted Planck spectrum through CIE 1931 color-matching functions
|
||||
and converts the resulting radiance to linear sRGB; it does not use an empirical
|
||||
color-temperature RGB approximation.
|
||||
|
||||
Point sources use a flux-normalized circular Moffat PSF by default
|
||||
(`--psf-fwhm-pixels 2.7 --psf-moffat-beta 4.5`). The FWHM matches the former
|
||||
1.15-pixel Gaussian core while the Moffat wings remain continuous; both values
|
||||
are display/optics calibration parameters.
|
||||
|
||||
Pass `--draw-mesh` to alpha-composite image-plane triangle edges as
|
||||
one-pixel-wide 0.5 linear-gray diagnostic lines at 0.5 opacity. The line
|
||||
rasterizer uses coverage-based antialiasing.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 2MASS Point Source Catalog sample
|
||||
|
||||
`raw/2mass_psc_m31_0p5deg.tbl` is an unmodified IPAC ASCII-table response from the
|
||||
NASA/IPAC Infrared Science Archive (IRSA) Gator service, drawn from the **2MASS
|
||||
All-Sky Point Source Catalog (PSC)**.
|
||||
|
||||
It is a circular field of view centred on M31:
|
||||
|
||||
- Centre: ICRS/J2000 RA 10.6847083 deg, Dec +41.2687500 deg
|
||||
- Cone radius: 0.5 deg
|
||||
- Retrieved rows: 9,047
|
||||
- Retrieval date: 2026-08-25 (recorded in the table header)
|
||||
|
||||
The all-sky PSC has hundreds of millions of entries, so it is deliberately not
|
||||
vendored. This real-survey slice keeps the repository lightweight while
|
||||
providing a reproducible replacement for the synthetic latitude/longitude grid.
|
||||
Run `bash scripts/download_2mass_psc_m31.sh` from any directory to refresh it.
|
||||
|
||||
The table preserves the service response. Its relevant source fields are:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `ra`, `dec` | ICRS/J2000 right ascension and declination, in degrees |
|
||||
| `j_m`, `h_m`, `k_m` | 2MASS J, H, and Ks default magnitudes, in mag |
|
||||
| `dist` | Angular distance from the stated field centre, in arcsec |
|
||||
| `rd_flg` | Per-band source/read flag needed to interpret the default magnitudes |
|
||||
|
||||
`j_m`, `h_m`, and `k_m` may be null in the original PSC. No temperature is
|
||||
included or inferred: the PSC provides photometry, not source temperatures.
|
||||
Do not treat magnitudes as linear fluxes without applying the appropriate 2MASS
|
||||
band zero points.
|
||||
|
||||
`processed/2mass_psc_m31_0p5deg_stars.csv` is generated from the raw table by
|
||||
`python3 scripts/process_2mass_psc.py`. It retains only `ra_deg`, `dec_deg`,
|
||||
`temperature_K`, and `amplitude_sr`. See its directory README for the fitting
|
||||
model and quality cut.
|
||||
|
||||
`raw/2mass_psc_m44_1p0deg.tbl` is the corresponding unmodified 1.0-degree
|
||||
cone around M44 (Beehive/Praesepe), centred on ICRS/J2000 RA 129.99165 deg and
|
||||
Dec +19.54139 deg. It contains 8,790 retrieved PSC rows (2026-08-25) and is
|
||||
downloaded by
|
||||
`bash scripts/download_2mass_psc_m44.sh`; process it with:
|
||||
|
||||
```sh
|
||||
python3 scripts/process_2mass_psc.py \
|
||||
--input assets/2mass/raw/2mass_psc_m44_1p0deg.tbl \
|
||||
--output assets/2mass/processed/2mass_psc_m44_1p0deg_stars.csv
|
||||
```
|
||||
|
||||
The resulting M44 CSV contains 7,802 sources after the documented three-band
|
||||
photometry and `rd_flg` selection.
|
||||
|
||||
Provenance: [IRSA Gator program interface](https://irsa.ipac.caltech.edu/docs/howto/gator_prog_interface.html),
|
||||
[2MASS PSC column descriptions](https://irsa.ipac.caltech.edu/2MASS/download/allsky/format_psc.html).
|
||||
@@ -0,0 +1,40 @@
|
||||
# Renderer-facing 2MASS star catalog
|
||||
|
||||
`2mass_psc_m31_0p5deg_stars.csv` is made from the corresponding table in
|
||||
`../raw/` by `python3 scripts/process_2mass_psc.py`.
|
||||
|
||||
The processor accepts `--input` and `--output`, so the same documented quality
|
||||
selection and fit are used for the M44 comparison field.
|
||||
|
||||
The CSV is intentionally compatible with the current four-column catalog
|
||||
loader: `ra_deg,dec_deg,temperature_K,amplitude_sr`. RA and Dec are ICRS/J2000
|
||||
degrees. The loader treats them as longitude and latitude when constructing
|
||||
the source-sky direction.
|
||||
|
||||
## Quality selection
|
||||
|
||||
All three J/H/Ks magnitudes must be finite and every character of `rd_flg` must
|
||||
be `1`, `2`, or `3`. These are the 2MASS default-magnitude origins that
|
||||
generally indicate the best detections, photometry, and astrometry. Thus the
|
||||
script rejects nondetections/upper limits (`0`), poor aperture photometry (`4`),
|
||||
inconsistent band deblends (`6`), and missing brightness estimates (`9`).
|
||||
|
||||
## Two-parameter fit
|
||||
|
||||
For J, H, Ks effective wavelengths 1.235, 1.662, and 2.159 micrometres and
|
||||
Vega zero points 1594, 1024, and 666.7 Jy, respectively, each magnitude is
|
||||
converted to flux density using `F_nu = F_nu,0 * 10^(-0.4 m)`.
|
||||
|
||||
The script then performs an unweighted least-squares fit in `log(F_nu)` to
|
||||
|
||||
`F_nu = amplitude_sr * B_nu(T)`.
|
||||
|
||||
`temperature_K` is constrained to 300--100000 K; `amplitude_sr` is the fitted
|
||||
apparent solid angle in steradians. The fit treats each band as monochromatic
|
||||
at its effective wavelength and does not deredden the source. This is the
|
||||
same two-parameter thermal-source reduction described qualitatively in the
|
||||
reference paper, but the paper does not publish its fit implementation; this
|
||||
script therefore records the numerical choice explicitly.
|
||||
|
||||
References: [2MASS `rd_flg` definitions](https://irsa.ipac.caltech.edu/data/2MASS/docs/releases/allsky/doc/sec1_6b.html),
|
||||
[2MASS zero points](https://www.ipac.caltech.edu/2mass/releases/allsky/faq.html).
|
||||
@@ -0,0 +1,299 @@
|
||||
# What does a binary black hole merger look like?
|
||||
|
||||
Andy Bohn,∗ William Throwe, Fran¸cois H´ebert, and Katherine Henriksson† Center for Radiophysics and Space Research, Cornell University, Ithaca, New York 14853, USA
|
||||
|
||||
Darius Bunandar
|
||||
|
||||
Theoretical Astrophysics 350-17, California Institute of Technology, Pasadena, California 91125, USA and MIT Kavli Institute, Massachusetts Institute of Technology, Cambridge, Massachusetts 02139, USA
|
||||
|
||||
Mark A. Scheel and Nicholas W. Taylor
|
||||
|
||||
Theoretical Astrophysics 350-17, California Institute of Technology, Pasadena, California 91125, USA (Dated: April 23, 2015)
|
||||
|
||||
We present a method of calculating the strong-field gravitational lensing caused by many analytic and numerical spacetimes. We use this procedure to calculate the distortion caused by isolated black holes and by numerically evolved black hole binaries. We produce both demonstrative images illustrating details of the spatial distortion and realistic images of collections of stars taking both lensing amplification and redshift into account. On large scales the lensing from inspiraling binaries resembles that of single black holes, but on small scales the resulting images show complex and in some cases self-similar structure across different angular scales.
|
||||
|
||||
PACS numbers: 95.30.Sf, 04.25.dg, 42.15.Dp Keywords: gravitational lensing, black holes, numerical relativity, ray tracing
|
||||
|
||||
## I. INTRODUCTION
|
||||
|
||||
Black holes are the most compact gravitating objects in the universe, with such strong gravitational fields that not even light can escape them. In the vicinity of a black hole, light rays can be very strongly deflected from a straight-line path, sometimes orbiting around the black hole before continuing on their way. It is now well-known that the bending of light by massive objects like galaxy clusters can create brightness amplification [1], deformed images, or even multiple images [2] of background objects such as quasars. These signatures have so far only been directly observed in cases where the deflection of light is very slight, up to approximately 11 arc seconds [3, 4]. However, here we are interested in the lensing effects associated with much more extreme bending of light near single or binary black holes, where the deflection angle is unbounded.
|
||||
|
||||
The lensing effects near general-relativistic bodies were first studied in the 1970s, with Cunningham and Bardeen [5] looking at a star on an orbit in a Kerr spacetime, and Luminet [6] studying an accretion disk around a Schwarzschild black hole. More recently, open-source codes such as GYOTO [7] and GeoViS [8] have produced images of lensing in the neighborhood of various compact objects. While the lensing caused by an isolated black hole has been understood analytically, the case of lensing by a binary black hole (BBH) is much more challenging because of the difficulty of solving for the geometry of the spacetime. With some arguably unrealistic assumptions (e.g., two maximally charged black holes in static equilibrium), analytic solutions can be found and subsequently used for lensing [8–13].
|
||||
|
||||

|
||||
FIG. 1. A pair of black holes that are about to merge, with the Milky Way visible in the background. Supplementary images and movies can be found at [14].
|
||||
|
||||
For astrophysically relevant binaries, however, we must instead rely on numerical solutions. Solving these binary spacetimes numerically to high accuracy has been possible for the last decade (see [15, 16] for a review), motivated by the need to provide gravitational-wave templates used by experiments such as LIGO, VIRGO, and KAGRA to make detections. By using the spacetimes computed in such simulations, we gain the ability to solve for the lensing effects in BBH systems.
|
||||
|
||||
In this paper, we focus on the question of what an observer in the vicinity of a BBH would actually see as the black holes orbit, spiral inward, and merge, with an example shown in figure 1. This is in contrast to most BBH visualizations, in which the positions or horizons of the two black holes are simply shown as a function of time in some coordinate system. We instead compute the paths of light rays that enter the observer’s eye or camera to find what would actually be seen. Furthermore, this path must be computed in the fully time-dependent spacetime, as the orbital velocities for a black-hole binary are typically large enough that the system cannot be approximated as time-independent during the time taken by the photons to travel across it.
|
||||
|
||||
Because the black holes themselves do not emit light (we ignore Hawking radiation, which is significant only for microscopic black holes), the observer would see nothing unless there is some additional light source. For illustrative purposes, we will take an artificial background “painted on” at infinity (figure 3) as the light source for most of our examples; this will allow us to study in detail where each light ray originates.
|
||||
|
||||
We begin by describing the problem setup and the methods that we use to generate lensing images in section II. In section III we show images of lensing by single and binary black holes, and we then conclude in section IV.
|
||||
|
||||
## II. METHODS
|
||||
|
||||
We set up the problem with our black hole(s) near the center of our chosen coordinate system. While any physical system representable by a spacetime metric can be used, we specialize in this paper to single and binary black holes. The observer (henceforth taken to be a camera) can be located anywhere in the space and is typically chosen to look towards the origin. A sphere with our light source encloses the black hole(s) and camera, infinitely far away.
|
||||
|
||||
To recreate the image taken by the camera in this configuration, we must find the properties of the light that arrives at each point on the camera’s image plane. A na¨ıve approach would be to trace all possible light rays (i.e., null geodesics) emanating from the light source to determine which rays reach the camera and from what directions they arrive, but this is computationally infeasible. A more efficient approach is to reverse the problem by tracing light rays away from the camera and backwards in time (the computer graphics community calls this a ray-casting algorithm). This method identifies the origin of any light ray that illuminates the camera, from which we infer the color and intensity of the corresponding photons as detected by the camera. When black holes are present, some of the null geodesics traced backwards in time from the camera may approach arbitrarily close to an event horizon as $t \to - \infty$ ; these geodesics correspond to dark image regions.
|
||||
|
||||
In what follows we describe how the light rays are traced from the camera using the geodesic language from general relativity. We show how we initialize these geodesics based on camera parameters such as position and viewing angle. Finally, we show how the origin of each light ray is determined and describe how the simulated image is constructed.
|
||||
|
||||
## A. Geodesic tracing
|
||||
|
||||
Our code can trace geodesics independently through either numerical or analytic metric data. It is common for numerical simulations to use the 3+1 decomposition [17], so we express the metric in the form
|
||||
|
||||
$$
|
||||
d s ^ { 2 } = - \alpha ^ { 2 } d t ^ { 2 } + \gamma _ { i j } ( d x ^ { i } + \beta ^ { i } d t ) ( d x ^ { j } + \beta ^ { j } d t ) ,\tag{1}
|
||||
$$
|
||||
|
||||
where α is the lapse function, $\beta ^ { i }$ is the shift vector, and $\gamma _ { i j }$ is the spatial metric.1 We obtain numerical data from simulations performed using the Spectral Einstein Code (SpEC) [18–22]. The geodesics are traced by evolving a solution to the geodesic equation
|
||||
|
||||
$$
|
||||
{ \frac { d ^ { 2 } x ^ { \lambda } } { d \tau ^ { 2 } } } + \Gamma ^ { \lambda } { } _ { \mu \nu } { \frac { d x ^ { \mu } } { d \tau } } { \frac { d x ^ { \nu } } { d \tau } } = 0 ,\tag{2}
|
||||
$$
|
||||
|
||||
where $x ^ { \lambda }$ is the four-position of the geodesic, τ is an affine parameter, and $\Gamma ^ { \lambda } { } _ { \mu \nu }$ are the Christoffel symbols describing the effective force caused by spacetime curvature.
|
||||
|
||||
To facilitate the numerical geodesic evolution, we split this second-order differential equation into two first-order differential equations using an intermediate, momentumlike variable such as $p ^ { \lambda } \overset { \cdot } { = } d x ^ { \lambda } / d \tau$ As we have some freedom in the definition of this momentum variable, we look for one that helps to minimize computational time and numerical errors when evolving through spacetimes with black holes.
|
||||
|
||||
We initially explored using the variable $p _ { \lambda } = g _ { \lambda \kappa } p ^ { \kappa }$ from Hughes et al. [23], along with converting the evolution equations from affine parameter $\tau$ to the coordinate time t of SpEC evolutions through the use of $p ^ { 0 } = d t / d \tau$ Although the resulting evolution equations are concise and have no time derivatives of metric variables, the variables $p ^ { 0 }$ and $p _ { i }$ grow exponentially near black hole horizons in typical coordinate systems used by SpEC simulations. This forces our time-stepper to take prohibitively small steps in order to achieve the desired accuracy.
|
||||
|
||||
We therefore choose a momentum variable slightly different than $p _ { \lambda }$ to mitigate this time-stepping problem. Null geodesics satisfy $p \cdot p = 0$ , which can be rewritten as $p ^ { 0 } = \alpha ^ { - 1 } ( \gamma ^ { i j } p _ { i } p _ { j } ) ^ { 1 / 2 }$ using the metric (1). This expression shows that $p ^ { 0 }$ and $p _ { i }$ scale similarly, so we can eliminate the exponential behavior of these variables by evolving the ratio. Our intermediate variable thus becomes
|
||||
|
||||
$$
|
||||
\Pi _ { i } \equiv \frac { p _ { i } } { \alpha p ^ { 0 } } = \frac { p _ { i } } { \sqrt { \gamma ^ { j k } p _ { j } p _ { k } } } ,\tag{3}
|
||||
$$
|
||||
|
||||
where we also divide by α to reduce the number of terms in the resulting evolution equations. Using $\Pi _ { i }$ and the $3 { + 1 }$ decomposition (1), we can express the geodesic equation (2) in the form
|
||||
|
||||
$$
|
||||
\begin{array} { l } { { \displaystyle \frac { d \Pi _ { i } } { d t } = - \alpha _ { , i } + ( \alpha _ { , j } \Pi ^ { j } - \alpha K _ { j k } \Pi ^ { j } \Pi ^ { k } ) \Pi _ { i } } } \\ { { \displaystyle ~ + \beta _ { , i } ^ { k } \Pi _ { k } - \frac { 1 } { 2 } \alpha \gamma ^ { j k } _ { , i } \Pi _ { j } \Pi _ { k } , } } \\ { { \displaystyle \frac { d x ^ { i } } { d t } = \alpha \Pi ^ { i } - \beta ^ { i } , } } \end{array}\tag{4}
|
||||
$$
|
||||
|
||||
where $K _ { j k }$ is the extrinsic curvature $( \mathrm { s e e } , \mathrm { e . g . } , [ 1 7 ] )$ and $\Pi ^ { i }$ is defined via the inverse spatial metric as $\Pi ^ { i } \equiv \dot { \gamma } ^ { i j } \Pi _ { j }$ Note that the geodesic equation consists of four secondorder equations, yet we only have three pairs of coupled first-order equations in (4). Because we are evolving a normalized momentum (3), we have lost information about $p ^ { 0 }$ during evolution. Compared to Hughes et al. [23], we have introduced a time derivative of the threemetric inside $K _ { j k }$ , but we have significantly sped up the evolution near black holes by removing the exponential growth of $p ^ { 0 }$ and $p _ { i }$
|
||||
|
||||
The equations in (4) are similar to those in (28) of Vincent et al. [24]. In fact our intermediate evolution variable $\Pi _ { i }$ is related to their variable $V ^ { i }$ by the threemetric, such that $\Pi ^ { i } = V ^ { i }$ . But our (4) has a reduced number of both temporal and spatial derivatives of metric quantities compared to Vincent’s (28).
|
||||
|
||||
During the backwards-in-time geodesic evolution, many geodesics are traced until they are far from the strong-field region, but some are traced until they encounter a black hole. These latter geodesics slowly converge towards the black hole’s event horizon, but as they can in principle be evolved indefinitely, we need some way of identifying them in finite time. We do this by monitoring $p ^ { 0 }$ for each geodesic, which (as discussed above) grows large near black hole horizons. Since our evolution equations (4) do not evolve $p ^ { 0 }$ , we must evolve another equation to keep track of it. However, we would still like to avoid the exponential growth of $p ^ { 0 }$ near the horizon. This can be accomplished by evolving the logarithm of $p ^ { 0 }$ . As was done in (3), we multiply $\bar { p } ^ { 0 }$ by the lapse to reduce the number of terms in the resulting equation, which gives the evolution variable $\ln ( \alpha p ^ { 0 } )$ . This leads to the evolution equation
|
||||
|
||||
$$
|
||||
\frac { d \ln ( \alpha p ^ { 0 } ) } { d t } = - \alpha _ { , i } \Pi ^ { i } + \alpha K _ { i j } \Pi ^ { i } \Pi ^ { j } .\tag{5}
|
||||
$$
|
||||
|
||||
When $p ^ { 0 }$ becomes too large, signaling a large energy, we flag the geodesic as originating from the black hole and we stop evolving it.
|
||||
|
||||
The remaining geodesics are those that originate from infinity, so we need to determine the $( \theta , \phi )$ location at infinity where they come from. In section II C, we will need the gravitational redshift z of each photon, which can be calculated from the ratio of the photon’s energy at the two ends of its trajectory via
|
||||
|
||||
$$
|
||||
1 + z = { \frac { E _ { \infty } } { E _ { \mathrm { c a m e r a } } } } ,\tag{6}
|
||||
$$
|
||||
|
||||
where $E _ { \infty }$ is the photon’s energy at infinity, and $E _ { c }$ camera is the photon’s energy as measured by the camera. Therefore we will need to compute the energy that each photon would have at infinity. In practice, these geodesics are traced backwards in time until they reach a large distance R from the black hole(s), chosen so that the metric at R is equal to the flat space metric within about a percent error. We use the approximation that the metric is exactly flat for $r > R$ . Under this approximation, the geodesic’s direction and $p ^ { 0 }$ at infinity are the same as at $R .$ . The direction is used to calculate a $( \theta , \phi )$ location on the sky, while $p ^ { 0 }$ is the photon’s energy at infinity, $E _ { \infty }$
|
||||
|
||||
## B. Initial data
|
||||
|
||||
Here we outline how we initialize our geodesic evolution variables. Because the geodesics are traced away from the camera, backwards in time, we initialize each geodesic’s evolution variables to their values at the camera. We have seven variables to set: three each for the initial position and momentum in (4), and one for the initial redshift in (5).
|
||||
|
||||
The initial position for every geodesic is simply the camera’s position. The initial momentum, however, is different for each geodesic and is dependent on the angle at which it enters the camera. We express the momenta in terms of an orthonormal tetrad defined as
|
||||
|
||||
$e _ { 0 } \colon$ The camera’s four-velocity, a timelike vector. For stationary cameras $e _ { 0 } \propto ( 1 , 0 , 0 , 0 )$ ;
|
||||
|
||||
$e _ { 1 } \colon$ The direction in which the camera is pointing;
|
||||
|
||||
$e _ { 2 } \colon$ The “upward” direction for the camera;
|
||||
|
||||
$_ { e 3 } \colon$ The “rightward” direction for the camera.
|
||||
|
||||
The four-vectors $e _ { 1 } , \ e _ { 2 }$ , and $e _ { 3 }$ are all spacelike, and their orientations in the camera’s reference frame are illustrated in figure 2.
|
||||
|
||||
In order to specify this tetrad, we give guesses for the vectors $e _ { 0 } , e _ { 1 }$ , and $e _ { 2 }$ , with the condition that the guessed time components of $e _ { 1 }$ and $e _ { 2 }$ must be zero. We then apply the Gram-Schmidt process to the sequence $e _ { 0 } , \ : e _ { 1 }$ and $e _ { 2 }$ to transform these vectors into an orthonormal set. The final vector, $e _ { 3 } .$ , is found by calculating the generalized cross product of the other three; explicitly,
|
||||
|
||||
$$
|
||||
e _ { 3 \rho } = \epsilon _ { \lambda \mu \nu \rho } e _ { 0 } { } ^ { \lambda } e _ { 1 } { } ^ { \mu } e _ { 2 } { } ^ { \nu } ,\tag{7}
|
||||
$$
|
||||
|
||||

|
||||
FIG. 2. Illustration of a pinhole camera in its rest frame with the three vectors $e _ { 1 } , e _ { 2 } ,$ , and $e _ { 3 }$ that describe its orientation. The inverted letter $^ { 6 } \mathrm { A } ^ { \dag }$ demonstrates the optical properties of the camera, which we correct for in the images we generate.
|
||||
|
||||
where $\epsilon _ { \lambda \mu \nu \rho }$ is the Levi-Civita tensor (see [25, p. 202] for more details).
|
||||
|
||||
Given the four orthonormal unit vectors, we can construct a null vector $\xi$ tangent to the geodesic that enters the camera from a given direction. The vector ξ will be proportional to the four-momentum of a photon following the geodesic; that is, $p = q \xi$ for some positive constant q. We define ξ by
|
||||
|
||||
$$
|
||||
\begin{array} { r } { \xi ^ { \lambda } { } _ { ( a , b ) } = C e _ { 0 } { } ^ { \lambda } - e _ { 1 } { } ^ { \lambda } - [ ( 2 b - 1 ) \tan ( \alpha _ { v } / 2 ) ] e _ { 2 } { } ^ { \lambda } } \\ { - [ ( 2 a - 1 ) \tan ( \alpha _ { h } / 2 ) ] e _ { 3 } { } ^ { \lambda } , } \end{array}\tag{8}
|
||||
$$
|
||||
|
||||
where $a , b \in [ 0 , 1 ]$ give the ray’s arrival direction in terms of fractions of the image’s horizontal and vertical lengths, respectively, and $\alpha _ { v } , \alpha _ { h }$ are the angular sizes of the camera aperture (field of view angles) in the vertical and horizontal directions. For the sign convention chosen in (8), $( a , b ) = ( 0 , 0 )$ corresponds to a photon seen at the bottom left corner of the image. We find C by requiring that ξ is null, i.e., $\xi \cdot \xi = 0 \colon$
|
||||
|
||||
$$
|
||||
C = \sqrt { 1 + ( 2 b - 1 ) ^ { 2 } \tan ^ { 2 } ( \alpha _ { v } / 2 ) + ( 2 a - 1 ) ^ { 2 } \tan ^ { 2 } ( \alpha _ { h } / 2 ) } .\tag{9}
|
||||
$$
|
||||
|
||||
We then use the metric to lower the index on $\xi ,$ and we compute the initial value of our evolution variable $\Pi _ { i }$ using $\bar { \Pi _ { i } } = p _ { i } / ( \alpha p ^ { 0 } ) = \xi _ { i } / ( \alpha \xi ^ { 0 } )$ . Note that $\Pi _ { i }$ is independent of the proportionality constant $q$ relating $\xi$ and the actual photon momentum $p ;$ physically, this is because the photon trajectory is independent of the photon energy. The only place where $q$ enters is in the initial value of $\alpha p ^ { 0 }$ in (5). We fix the value of $q$ by demanding that the energy of the photon in the frame of the camera be unity when the photon strikes the camera, so $E _ { \mathrm { c a m e r a } } = 1$ in (6).
|
||||
|
||||
## C. Image generation
|
||||
|
||||
We create our image of the physical system by dividing the image plane into rectangular regions corresponding to the pixels of the output image and assigning an appropriate color to each region. Because each region has an extended size, there is no single source point we can look at to obtain its color, so we must adopt some prescription for assigning a single color to each pixel. We use two different prescriptions, based on the nature of the light source illuminating the system.
|
||||
|
||||

|
||||
FIG. 3. An illustration of our artificial background grid “painted $\mathrm { o n } ^ { \mathrm { 7 } }$ a sphere at infinity. This background is used for all the images with a grid in this paper. In the figure, we cut a window out of the sphere to show the inside. In addition to four colors differentiating the regions of the sphere, we include a white reference spot in the direction in which the camera is pointing.
|
||||
|
||||
For extended sources, such as the artificial grid in figure 3, we use a subpixel sampling method. On each pixel we construct an evenly spaced grid of points, and at each of these points we determine where incident light rays originate, either from one of the holes or a location at infinity. We assign a color to each grid point based on that of the corresponding source point; the color of the pixel is then the average of these. We find that a grid of $4 \times 4$ sample points gives sufficiently smooth images without too much computational cost. For these images, we neglect the effects of redshift and focus on the spatial distortions.
|
||||
|
||||
To create more astronomically relevant images, we wish to use a collection of point sources (i.e., stars) as our illumination. In this case we cannot determine a pixel’s color using sampling, but must instead sum the contributions from all the point sources contributing light there. For our list of sources, we use about $3 . 4 \times 1 0 ^ { 8 }$ stars from the Two Micron All Sky Survey (2MASS) [26]. To simplify computations, we approximate each star as a thermal source with temperature and brightness determined by fitting to the photometric information in the catalog. When we calculate the contribution of each star to the light arriving at the camera, we must account not only for its properties as a light source, but also for the effects of the spacetime curvature encountered by the photon. These effects come in two forms. First, the observed energies of photons at the camera will be modified by redshift effects, changing sources’ apparent brightnesses and temperatures. Second, the spatial convergence or divergence of nearby geodesics produces an overall adjustment to each source’s apparent brightness without affecting its spectrum. Both of these effects are discussed in detail in Mollerach and Roulet [27]. After we have drawn the entire image in this manner, we convolve it with a blurring function to make the stars more visible. This has the effect of transforming each star into a fuzzy circle with size dependent on its brightness.
|
||||
|
||||
The result of this scheme can be seen in figure 1, which shows the BBH image from figure 11 in front of a background of stars. Note that by generating our starfield images from a catalog of point sources, we obtain a substantially more realistic image than would be generated by applying the lensing deformation to a raster image of the unlensed Milky Way stars. In such a raster image, each star is usually represented (whether as a result of camera optics or software rendering) as a blurred circle whose area depends on the star’s brightness. These circles are typically hundreds of arc seconds wide, and therefore lensing distortions applied to the image tend to produce stars that appear as smeared ellipses. In contrast, the angular sizes of real stars are many orders of magnitude smaller, so we expect them to remain as unresolved points under all but the most extreme lensing magnifications. These unresolved points can then be rendered as previously described, giving stars that better portray what an observer would actually see (as in figure 1). The difference between these methods lies in the non-commutativity between the lensing deformations and the blurring of each star. A minor shortcoming of our method arises at Einstein rings (discussed in section III A), where the magnification diverges. There a star could in principle (though with very low probability) appear as an extended object, but in our treatment it would remain point-like. On the other hand, blurring first and then lensing is almost guaranteed to produce unphysical extended streaks at the Einstein ring.
|
||||
|
||||
## III. RESULTS
|
||||
|
||||
Before applying our lensing code to binary black hole systems, we generate images of simpler analytic spacetimes. These serve both to provide checks that our images are consistent with earlier work, and also to illustrate general features of lensing around black holes that will appear again in BBH images. We then proceed to show two different configurations of BBH mergers.
|
||||
|
||||
To help visualize the lensing, we divide our light source at infinity into colored quadrants with a superimposed grid. An external view of this sphere is shown in figure 3. In addition to the colored sections, our light source has a bright reference spot in the direction towards which we point our camera. This spot will prove useful in illustrating an important feature of black hole lensing called an Einstein ring.
|
||||
|
||||
## A. Analytic spacetimes
|
||||
|
||||
In figure 4, we compare a flat space image with the images obtained by lensing our light source through Schwarzschild and Kerr black hole spacetimes. The top row from left to right shows flat Minkowski space and a
|
||||
|
||||

|
||||
FIG. 4. Lensing caused by various analytic spacetimes. For all panels, we use figure 3 as a background, oriented such that the camera is pointed at the white reference dot. The camera has a 60◦ field of view and is at a distance of 15 Schwarzschild radii from the origin measured using Kerr-Schild coordinates [25]. The top row shows Minkowski and Schwarzschild spacetimes. The bottom row shows two views of the Kerr spacetime, with dimensionless spin χ = 0.95, viewed with the camera pointing parallel to the spin axis of the black hole (bottom left) and perpendicular to the spin axis (bottom right).
|
||||
|
||||
Schwarzschild black hole. These spacetimes are spherically symmetric, so viewing them from different angles produces the same lensing effects. The bottom row shows a Kerr black hole, where in the left frame the spin vector is pointing out of the page and in the right frame it is pointing up. Here the spin breaks the spherical symmetry of the spacetime, leading to different lensing effects from different viewing directions.
|
||||
|
||||
In Minkowski space in the top left image we expect no deflection of light, which is what we observe. The camera sees an upright image of the portion of the grid near the white dot. The bowing of the grid lines is an expected geometric effect of viewing a latitude-longitude grid.
|
||||
|
||||
In the top right image, we see the lensing effects of a non-spinning black hole. The black circle in the center of the image is called the shadow of the black hole, where the hole prevents any light from reaching the camera. Alternatively, a shadow is a region of the image where geodesics are traced backwards in time from the camera to a black hole. Another prominent feature is that the white dot on our grid at infinity has been lensed into a large ring, called an Einstein ring [28]. Light from the point situated directly on the opposite side of the black hole, the antipodal point, will by symmetry be lensed into a ring around the black hole as observed by our camera. Regions inside the Einstein ring correspond to photons that are deflected by larger angles than are the Einstein ring photons; this results in an inverted image of the reference grid inside the Einstein ring. A second Einstein ring can be seen near the shadow, corresponding to light from a source behind the camera wrapping around the hole on its way to the camera. In fact, photons can wind an arbitrarily large number of times around the black hole, resulting in an infinite number of Einstein rings.
|
||||
|
||||
The bottom row of figure 4 shows a single black hole with a large dimensionless spin of $\chi = 0 . 9 5$ . As in the Schwarzschild case, there is an Einstein ring around the black hole shadow as well as image inversion inside the Einstein ring. However, for the case of a Kerr spacetime, the light coming from the Einstein ring does not originate from a single point directly behind the black hole, but from a small region (unless the camera is pointing directly along the spin axis). The spin of the black hole causes frame dragging, where space is dragged in the direction of the rotation [29, 30]. In the bottom left image, the spin axis of the black hole is pointing out of the page, so space is dragged in a counterclockwise motion. The effect of the frame dragging on the photon trajectories produces an image in which the grid itself appears to be dragged by the spin, as is evident when compared to the non-spinning black hole in the top right image. The strength of frame dragging increases closer to the black hole, which can also be inferred from the deformation of the background grid.
|
||||
|
||||
Frame dragging manifests differently in the bottom right image, where the spin axis is pointing up. The direction of frame dragging is out of the page on the left of the shadow of the black hole and into the page on the right. A photon traveling in the direction of the frame dragging can orbit closer to the black hole without being captured than a photon traveling opposite the frame dragging direction, resulting in an asymmetrical shadow about the spin axis. This causes the shadow to appear offset relative to the shadow of a Schwarzschild hole.
|
||||
|
||||
## B. Binary black hole spacetimes
|
||||
|
||||
Astrophysical black hole binaries are expected to radiate energy via gravitational waves, leading to a long inspiral followed by a merger, and finally a ringdown to a steady-state single black hole. Lensing by a final, steady-state black hole will look like the single black holes already seen in figure 4. However, the situation becomes more interesting when viewing these systems before merger. The first images we will present show an equal-mass BBH with non-spinning black holes—one of the simplest binary inspiral spacetimes to analyze— shortly before merger. The simulation we use is case 1 of Taylor et al. [31].
|
||||
|
||||
Figure 5 shows the image of our reference grid in the presence of this BBH, where the camera is situated such that the orbital angular momentum is pointing out of the page. This image bears a striking resemblance to the bottom left frame of figure 4, excluding the details near the shadows. This shows that, away from the shadows, the spacetime looks similar to a single rotating black hole, where the lensing is dominated by the mass monopole with corrections caused by the angular momentum of the system. In the single-hole case, the spin is responsible for frame dragging, whereas here the orbital angular momentum is responsible.
|
||||
|
||||

|
||||
FIG. 5. A BBH system of equal-mass black holes with no spin, viewed near merger with the orbital angular momentum out of the page.
|
||||
|
||||

|
||||
FIG. 6. A cropped version of figure 5 in order to show more detail near the black hole shadows. A small portion of the image (outlined) is enlarged and inset, where a smaller eyebrow is clearly visible.
|
||||
|
||||
Focusing on the inner portion of the image, we observe that the binary lensing is markedly different from the Schwarzschild or Kerr cases. Figure 6 shows a cropped version of figure 5, emphasizing the structure of the shadows. As might be expected, there are two prominent shadows visible, each associated with one of the two black holes. We also see a narrow secondary shadow (an “eyebrow” [13]) close to the outside of each primary shadow.
|
||||
|
||||

|
||||
FIG. 7. The same system as figure 6, viewed such that the orbital angular momentum of the system is pointing up. Note that the grid lines in the inset are shown in gray here to distinguish them from the black hole shadows.
|
||||
|
||||
These secondary shadows correspond to one black hole (BH) casting a shadow which is lensed by the other BH on the way to the camera. Equivalently, they are image regions where geodesics are traced backwards from the camera to a BH, but bend around at least one BH on the way there. The first pair of eyebrows is evident in figure $6 ;$ however, we can resolve a pair of smaller eyebrows, shown in the inset.
|
||||
|
||||
We show another view of the same system in figure 7. Here the camera is looking at the system edge on, such that the orbital angular momentum is pointing up. We see again an overall similarity with the corresponding orientation of Kerr spacetime (the bottom-right frame of figure 4), indicating the dominant effects of the mass and angular momentum in these images. We can see a primary shadow for each black hole, but in this configuration one black hole is located roughly behind the other and as a result its shadow gets lensed into a dark ring. Extending along the right side of this ring we see a long thin eyebrow, which is shown in the inset, along with another, smaller, eyebrow.
|
||||
|
||||
To illustrate how photon trajectories behave near shadows, we plot trajectories of a few geodesics on the horizontal line passing through the middle of figure 7 near the eyebrow. Figure 8 shows four snapshots of these trajectories in time, with their current locations in each frame denoted by large dots. It is easiest to consider these trajectories as we evolve them, out of the camera and backwards in time, to see where they came from. In frames A–C, we see the trajectories under consideration start close together then diverge significantly, demonstrating how nearby pixels on the image can correspond to vastly different physical locations. In frame D we see the entire trajectories. A few extend to infinity, but most terminate on the black holes; these are denoted by solid lines and dotted lines, respectively. Only the trajectories extending to infinity result in a photon reaching the camera; those that reach the hole on the right of frame D correspond to the primary ring-like shadow in figure 7, while those that reach the left hole correspond to the larger eyebrow visible on the right side of figure 7. Note that the black holes are orbiting rapidly, so they move significantly while the photons pass through the system.
|
||||
|
||||

|
||||
|
||||
FIG. 8. Geodesic trajectories plotted in relation to the black hole event horizons during the lensing evolution for figure 7. Each frame shows a snapshot in time, with the dots representing the current positions of the geodesics, and the lines indicating the trajectories from the camera. The solid and dashed lines indicate whether the geodesics originate from infinity or from a black hole, respectively.
|
||||

|
||||
FIG. 9. Plots identifying the origins of photons along the horizontal line through the center of figure 7. Photons coming from infinity are labeled $\infty ,$ and the shadows are labeled either BH 1 or BH 2. The first plot corresponds to the main portion of figure 7. The second plot focuses on the zoomed square in the inset of figure 7, showing a small feature of the first plot. The third plot zooms to a similar feature of the second plot. This figure demonstrates a striking self-similarity of the lensing structure of a binary black hole system.
|
||||
|
||||
We can also uniquely identify which black hole casts each shadow, which enables us to show in figure 9 the origin of the photons along the horizontal line across the center of figure 7. We arbitrarily label the large shadow in the middle of figure 7 as BH 2, and the ring-like shadow as BH 1. Regions where photons reach the camera from infinity are labeled $\infty .$ . The top plot in figure 9 shows the origin of the photons that reach the camera along the entire middle horizontal line in figure 7. We see that each transition from $\infty$ to either of the BHs includes transitions to the other BH. Even though we cannot resolve them numerically, each vertical line in principle contains infinitely many transitions. To illustrate this idea, the second plot in figure 9 investigates the group of shadows indicated by the zoomed inset of figure 7. Here we find a structure which resembles the first plot. The third plot in figure 9 zooms to a similar group of shadows on the right side of the second plot to again reveal the same structure. This figure clearly shows evidence of self-similarity in the structure of BBH lensing, where the smaller length scales explore more photon orbits through the system. Furthermore, the structure of shadows in BBH lensing is more complex than figures 6 and 7 appear to suggest. The shadows these images focus on are merely some of the largest visible shadows, associated with simpler geodesic orbits around the binary.
|
||||
|
||||
If we consider this equal-mass BBH earlier in the inspiral when its separation is large, the black holes are only weakly interacting. Therefore most camera viewpoints of this binary will yield images with two primary shadows, one for each black hole. Each shadow will be similar to an isolated Schwarzschild or Kerr shadow but with the addition of small eyebrows. However, when the binary is viewed edge-on and the black holes are nearly aligned with the camera, we see an interesting image.
|
||||
|
||||
Figure 10 shows the equal-mass binary in this configuration, hundreds of orbits before merger. Just as in figure 7, the more distant black hole is lensed into a ringlike shadow; however, the ring is thinner here, primarily because of the large separation of the binary. The angular momentum causes the lensed grid outside the shadows to strongly resemble lensing by a Kerr black hole rather than lensing by a Schwarzschild black hole. In addition to the usual primary Einstein ring, another ring is visible between these shadows. Both of these rings correspond to the same source of light, which is in front of the camera and behind the BBH. The second Einstein ring is caused by photons following an “S”-shaped trajectory through the system.
|
||||
|
||||
The second binary system we consider is a fully generic black hole binary with a mass ratio of $m _ { 1 } / m _ { 2 } = 3$ and black hole spins of $\chi _ { 1 } = 0 . 7$ and $\chi _ { 2 } = 0 . 3$ in arbitrary directions. This is case 4 of Taylor et al. [31]. In figure 11 we see a top view of this system, in analogy with what is presented in figure 6. Away from the shadows, the lensing is similar to a single black hole with spin, as was seen with the equal-mass binary images. This appears to be a generic feature of lensing from orbiting BBHs. We can clearly see that the symmetry present in the equal-mass system is gone. The unequal masses evidently change the relative sizes of not only the primary shadows, but all additional shadows as well. The inset in figure 11 zooms to show two successively smaller eyebrows near the small black hole’s primary shadow. However, the effects of the black holes’ spins are not at all clear from this viewpoint.
|
||||
|
||||

|
||||
FIG. 10. A BBH system of equal-mass black holes with no spin, viewed hundreds of orbits before merger, with the orbital angular momentum pointing up. The distance from the camera to the closer black hole in this figure is the same as in figure 7. Note that the grid lines are shown in gray here to distinguish them from the black hole shadows.
|
||||
|
||||

|
||||
FIG. 11. A view of a binary inspiral of mass ratio $m _ { 1 } / m _ { 2 } = 3$ near merger, with the orbital angular momentum approximately pointing out of the page. The black hole spins are $\chi _ { 1 } = 0 . 7$ and $\chi _ { 2 } = 0 . 3$ in arbitrary directions. This figure is analogous to figure 6. As in previous figures, a small portion of the image is enlarged and inset, displaying additional eyebrows.
|
||||
|
||||
In figure 12 we see the same binary as in figure 11, viewed with the orbital angular momentum pointing upward, in analogy with figure 7. We again see that, away from the shadows, the system looks like a Kerr black hole. The unequal mass ratio is apparent here, with the smaller black hole lensing the shadow of the larger black hole into a partial ring. If it were not for the black hole spins, the lensing by the binary would be symmetric, giving either a ring-like shadow similar to figure 7 or a shadow and a very thick eyebrow. In this particular BBH, the effect of the individual black hole spins on the image depends strongly on the camera position.
|
||||
|
||||

|
||||
FIG. 12. Another view of the BBH in figure 11, but with orbital angular momentum pointing up. The camera parameters are otherwise identical. This figure is analogous to figure 7; however, because of the asymmetry from the black hole spins, the larger black hole’s shadow is not lensed completely around the small black hole.
|
||||
|
||||
## IV. CONCLUSIONS
|
||||
|
||||
In this paper, we present the first images of gravitational lensing by astrophysically relevant binary black holes, thereby providing a realistic representation of what an observer near such a system would actually see. To accomplish this, we have developed a new set of equations that evolve photons efficiently near black hole horizons. Our images show there is a primary shadow—a region where the black hole prevents light from reaching the camera—for each black hole, as well as multiple secondary shadows (or eyebrows).
|
||||
|
||||
We have found that, early in the inspiral, images of a BBH look similar to two separate Kerr black hole shadows, unless viewed when the holes are nearly collinear with the camera. Shortly before the merger, all camera angles yield interesting images of not just one shadow for each black hole, but a handful of smaller visible shadows. We showed for an equal-mass binary viewed edgeon that the lensing structure exhibits self-similarity on smaller scales, corresponding to photons taking an increasing number of orbits through the system. Lensing by a fully generic BBH illustrated that the spin of black holes in a binary can have a clear effect on the lensed shadows.
|
||||
|
||||
We chose not to classify eyebrows and shadows into a hierarchy in this paper. In the inset of figure 6, for instance, identifying the largest eyebrow as the primary eyebrow and the next largest as the secondary eyebrow feels very natural, but the exact definition of such a hierarchy is not immediately clear. For example, simply specifying a geodesic winding number around each black hole is likely not to be sufficient. In addition to the trajectories not lying in a plane, the order that a geodesic orbits the black holes does not commute. Furthermore, the black holes are moving at comparable speeds to the geodesics. For these reasons, we leave the task of classifying shadows as future work.
|
||||
|
||||
We have also shown in this paper that, away from the shadows, an image of a binary black hole system looks like that of an isolated black hole. Thus it is necessary to resolve individual shadows in order to discern the unique visual characteristics present in such images, which places limits on our ability to observe them.
|
||||
|
||||
For systems involving matter, however, the combination of the lensing effects of strong gravity with the disruption and distortion of radiation-emitting matter might yield a unique optical signature. Generating lensed images of black hole-neutron star and neutron starneutron star mergers is an avenue of future investigation. The techniques presented here would allow us to produce detailed visualizations of these mergers; integrating over such images, we could predict the optical signature of an unresolved system.
|
||||
|
||||
## ACKNOWLEDGMENTS
|
||||
|
||||
We would like to thank Curran Muhlberger for providing the temperature fits to the 2MASS photometric data. This publication makes use of data products from the Two Micron All Sky Survey, which is a joint project of the University of Massachusetts and the Infrared Processing and Analysis Center/California Institute of Technology, funded by the National Aeronautics and Space Administration and the National Science Foundation. We would like to thank Daniel Hemberger and Saul Teukolsky for comments on an earlier version of this paper. The authors from Cornell would also like to thank Saul Teukolsky and Lawrence Kidder for general advice while writing this paper.
|
||||
|
||||
This work was supported in part by NSF Grants PHY-1306125 and AST-1333129 at Cornell University, by NSF Grants PHY-1440083, AST-1333520, PHY-1005655, and DMS-1065438 at the California Institute of Technology, and by a grant from the Sherman Fairchild Foundation. FH acknowledges support by the NSF Graduate Research Fellowship under Grant No. DGE-1144153. DB acknowledges support from the LIGO Laboratory, with funding from the National Science Foundation under cooperative agreement PHY-0757058 and NSF REU award PHY-1062293. The binary black hole simulations were performed using the Zwicky computer system operated by the Caltech Center for Advanced Computing Research and funded by NSF MRI No. PHY-0960291 and the Sherman Fairchild Foundation.
|
||||
|
||||
[1] Sjur Refsdal. The gravitational lens effect. Monthly Notices of the Royal Astronomical Society, 128(4):295–306, 1964.
|
||||
|
||||
[2] R. D. Blandford and R. Narayan. Cosmological applications of gravitational lensing. Annual Review of Astronomy and Astrophysics, 30(1):311–358, 1992.
|
||||
|
||||
[3] Naohisa Inada, Masamune Oguri, Bartosz Pindor, Joseph F Hennawi, Kuenley Chiu, Wei Zheng, Shin-Ichi Ichikawa, Michael D Gregg, Robert H Becker, Yasushi Suto, et al. A gravitationally lensed quasar with quadruple images separated by 14.62 arcseconds. Nature, 426(6968):810–812, 2003.
|
||||
|
||||
[4] Naohisa Inada, Masamune Oguri, Tomoki Morokuma, Mamoru Doi, Naoki Yasuda, Robert H. Becker, Gordon T. Richards, Christopher S. Kochanek, Issha Kayo, Kohki Konishi, Hiroyuki Utsunomiya, Min-Su Shin, Michael A. Strauss, Erin S. Sheldon, Donald G. York, Joseph F. Hennawi, Donald P. Schneider, Xinyu Dai, and Masataka Fukugita. SDSS J1029+2623: A gravitationally lensed quasar with an image separation of 22.005. The Astrophysical Journal Letters, 653(2):L97, 2006.
|
||||
|
||||
[5] C. T. Cunningham and J. M. Bardeen. The Optical Appearance of a Star Orbiting an Extreme Kerr Black Hole. Astrophys. J. Lett., 173:L137, May 1972.
|
||||
|
||||
[6] J.-P. Luminet. Image of a spherical black hole with thin accretion disk. Astron. Astrophys., 75:228–235, May 1979.
|
||||
|
||||
[7] F.H. Vincent, T. Paumard, E. Gourgoulhon, and G. Perrin. GYOTO: a new general relativistic ray-tracing code. Class.Quant.Grav., 28:225011, 2011, 1109.4769.
|
||||
|
||||
[8] Thomas M¨uller. GeoViS—Relativistic ray tracing in four-dimensional spacetimes. Computer Physics Communications, 185(8):2301 – 2308, 2014.
|
||||
|
||||
[9] Sudhansu Datta Majumdar. A class of exact solutions of Einstein’s field equations. Phys. Rev., 72:390–398, Sep 1947.
|
||||
|
||||
[10] Achilles Papapetrou. A static solution of the equations of the gravitational field for an arbitrary chargedistribution. Proc. Roy. Irish Acad., A 51:191–204, 1947.
|
||||
|
||||
[11] David Kastor and Jennie Traschen. Cosmological multiblack-hole solutions. Phys. Rev. D, 47:5370–5375, Jun 1993.
|
||||
|
||||
[12] Daisuke Nitta, Takeshi Chiba, and Naoshi Sugiyama. Shadows of colliding black holes. Phys. Rev. D, 84:063008, Sep 2011.
|
||||
|
||||
[13] Akifumi Yumoto, Daisuke Nitta, Takeshi Chiba, and Naoshi Sugiyama. Shadows of multi-black holes: Analytic exploration. Phys. Rev. D, 86:103001, Nov 2012.
|
||||
|
||||
[14] http://www.black-holes.org/lensing.
|
||||
|
||||
[15] Joan Centrella, John G. Baker, Bernard J. Kelly, and James R. van Meter. Black-hole binaries, gravitational waves, and numerical relativity. Rev.Mod.Phys., 82:3069, 2010, 1010.5260.
|
||||
|
||||
[16] Harald P. Pfeiffer. Numerical simulations of compact
|
||||
|
||||
object binaries. Class.Quant.Grav., 29:124004, 2012, 1203.5166.
|
||||
|
||||
[17] R. Arnowitt, S. Deser, and Charles W. Misner. The dynamics of general relativity. In L. Witten, editor, Gravitation: An Introduction to Current Research, pages 227– 265. Wiley, New York, 1962, gr-qc/0405109.
|
||||
|
||||
[18] http://www.black-holes.org/SpEC.html.
|
||||
|
||||
[19] Simulating eXtreme Spacetimes. http://www. black-holes.org/.
|
||||
|
||||
[20] B´ela Szil´agyi, Lee Lindblom, and Mark A. Scheel. Simulations of binary black hole mergers using spectral methods. Phys. Rev. D, 80:124010, 2009, 0909.3557.
|
||||
|
||||
[21] Daniel A. Hemberger, Mark A. Scheel, Lawrence E. Kidder, B´ela Szil´agyi, Geoffrey Lovelace, Nicholas W. Taylor, and Saul A. Teukolsky. Dynamical excision boundaries in spectral evolutions of binary black hole spacetimes. Class. Quantum Grav., 30(11):115001, 2013, 1211.6079.
|
||||
|
||||
[22] http://www.black-holes.org/waveforms.
|
||||
|
||||
[23] S. A. Hughes, C. R. Keeton, P. Walker, K. T. Walsh, S. L. Shapiro, and S. A. Teukolsky. Finding black holes in numerical spacetimes. Phys. Rev. D, 49:4004, 1994.
|
||||
|
||||
[24] F.H. Vincent, E. Gourgoulhon, and J. Novak. 3+1 geodesic equation and images in numerical spacetimes. Classical and Quantum Gravity, 29(24):245005, 2012.
|
||||
|
||||
[25] Charles W. Misner, Kip S. Thorne, and John Archibald Wheeler. Gravitation. Freeman, New York, New York, 1973.
|
||||
|
||||
[26] M. F. Skrutskie, R. M. Cutri, R. Stiening, M. D. Weinberg, S. Schneider, J. M. Carpenter, C. Beichman, R. Capps, T. Chester, J. Elias, J. Huchra, J. Liebert, C. Lonsdale, D. G. Monet, S. Price, P. Seitzer, T. Jarrett, J. D. Kirkpatrick, J. E. Gizis, E. Howard, T. Evans, J. Fowler, L. Fullmer, R. Hurt, R. Light, E. L. Kopan, K. A. Marsh, H. L. McCallon, R. Tam, S. Van Dyk, and S. Wheelock. The two micron all sky survey (2MASS). The Astronomical Journal, 131(2):1163, 2006.
|
||||
|
||||
[27] Silvia Mollerach and Esteban Roulet. Gravitational Lensing and Microlensing. World Scientific, 2002.
|
||||
|
||||
[28] Albert Einstein. Lens-like action of a star by the deviation of light in the gravitational field. Science, 84(2188):506–507, 1936.
|
||||
|
||||
[29] H. Thirring. Uber die Wirkung rotierender ferner Massen ¨ in der Einsteinschen Gravitationstheorie. Physikalische Zeitschrift, 19:33, 1918.
|
||||
|
||||
[30] H. Thirring. Berichtigung zu meiner Arbeit: “Uber die ¨ Wirkung rotierender Massen in der Einsteinschen Gravitationstheorie”. Physikalische Zeitschrift, 22:29, 1921.
|
||||
|
||||
[31] Nicholas W. Taylor, Michael Boyle, Christian Reisswig, Mark A. Scheel, Tony Chu, Lawrence E. Kidder, and B´ela Szil´agyi. Comparing gravitational waveform extrapolation to Cauchy-characteristic extraction in binary black hole simulations. Phys. Rev. D, 88:124010, Dec 2013, 1309.3605.
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download a small, versionable 2MASS PSC field from the IRSA Gator API.
|
||||
#
|
||||
# The query is intentionally a 0.5-degree cone instead of the full PSC: the
|
||||
# all-sky catalog contains hundreds of millions of sources and is not suitable
|
||||
# for this repository. The output remains an original IPAC table so no
|
||||
# photometry or missing-value handling is silently changed.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
output="$repo_root/assets/2mass/raw/2mass_psc_m31_0p5deg.tbl"
|
||||
tmp_output=$(mktemp "${TMPDIR:-/tmp}/2mass_psc_m31_XXXXXX.tbl")
|
||||
trap 'rm -f "$tmp_output"' EXIT
|
||||
|
||||
mkdir -p "$(dirname -- "$output")"
|
||||
|
||||
curl --fail --silent --show-error --location --get \
|
||||
'https://irsa.ipac.caltech.edu/cgi-bin/Gator/nph-query' \
|
||||
--data-urlencode 'catalog=fp_psc' \
|
||||
--data-urlencode 'spatial=cone' \
|
||||
--data-urlencode 'objstr=10.6847083 41.26875' \
|
||||
--data-urlencode 'radius=0.5' \
|
||||
--data-urlencode 'radunits=deg' \
|
||||
--data-urlencode 'outfmt=1' \
|
||||
--data-urlencode 'selcols=designation,ra,dec,j_m,h_m,k_m' \
|
||||
--data-urlencode 'outrows=50000' \
|
||||
-o "$tmp_output"
|
||||
|
||||
grep -q "2MASS All-Sky Point Source Catalog" "$tmp_output"
|
||||
grep -q '^| *designation|' "$tmp_output"
|
||||
mv "$tmp_output" "$output"
|
||||
trap - EXIT
|
||||
|
||||
printf 'Wrote %s\n' "$output"
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download the M44 (Beehive/Praesepe) 2MASS PSC comparison field from IRSA.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
output="$repo_root/assets/2mass/raw/2mass_psc_m44_1p0deg.tbl"
|
||||
tmp_output=$(mktemp "${TMPDIR:-/tmp}/2mass_psc_m44_XXXXXX.tbl")
|
||||
trap 'rm -f "$tmp_output"' EXIT
|
||||
|
||||
mkdir -p "$(dirname -- "$output")"
|
||||
|
||||
curl --fail --silent --show-error --location --get \
|
||||
'https://irsa.ipac.caltech.edu/cgi-bin/Gator/nph-query' \
|
||||
--data-urlencode 'catalog=fp_psc' \
|
||||
--data-urlencode 'spatial=cone' \
|
||||
--data-urlencode 'objstr=129.99165 19.54139' \
|
||||
--data-urlencode 'radius=1.0' \
|
||||
--data-urlencode 'radunits=deg' \
|
||||
--data-urlencode 'outfmt=1' \
|
||||
--data-urlencode 'selcols=designation,ra,dec,j_m,h_m,k_m' \
|
||||
--data-urlencode 'outrows=100000' \
|
||||
-o "$tmp_output"
|
||||
|
||||
grep -q "2MASS All-Sky Point Source Catalog" "$tmp_output"
|
||||
grep -q '^| *designation|' "$tmp_output"
|
||||
mv "$tmp_output" "$output"
|
||||
trap - EXIT
|
||||
|
||||
printf 'Wrote %s\n' "$output"
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fit blackbody temperature and apparent brightness to a 2MASS PSC table.
|
||||
|
||||
The output is deliberately a small renderer-facing CSV: RA, Dec, blackbody
|
||||
temperature, and blackbody normalization. The latter is the fitted apparent
|
||||
solid angle Omega in F_nu = Omega B_nu(T), expressed in steradians.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import argparse
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
INPUT = ROOT / "assets/2mass/raw/2mass_psc_m31_0p5deg.tbl"
|
||||
OUTPUT = ROOT / "assets/2mass/processed/2mass_psc_m31_0p5deg_stars.csv"
|
||||
|
||||
# 2MASS effective wavelengths and Vega zero-magnitude flux densities.
|
||||
WAVELENGTH_M = np.array([1.235, 1.662, 2.159]) * 1e-6
|
||||
ZERO_POINT_JY = np.array([1594.0, 1024.0, 666.7])
|
||||
GOOD_RD_FLAGS = frozenset("123")
|
||||
MIN_TEMPERATURE_K = 300.0
|
||||
MAX_TEMPERATURE_K = 100000.0
|
||||
|
||||
PLANCK_H = 6.62607015e-34
|
||||
LIGHT_C = 299792458.0
|
||||
BOLTZMANN_K = 1.380649e-23
|
||||
|
||||
|
||||
def column_slices(path: Path) -> tuple[list[str], list[tuple[int, int]], int]:
|
||||
"""Return IPAC fixed-width column metadata and first data-line index."""
|
||||
lines = path.read_text(encoding="ascii").splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith("|") and "designation" in line:
|
||||
boundaries = [offset for offset, char in enumerate(line) if char == "|"]
|
||||
names = [line[left + 1:right].strip()
|
||||
for left, right in zip(boundaries, boundaries[1:])]
|
||||
slices = [(left + 1, right)
|
||||
for left, right in zip(boundaries, boundaries[1:])]
|
||||
return names, slices, index + 4
|
||||
raise ValueError(f"no IPAC table header in {path}")
|
||||
|
||||
|
||||
def load_required_columns(path: Path) -> dict[str, np.ndarray]:
|
||||
names, slices, first_data_line = column_slices(path)
|
||||
required = ("ra", "dec", "j_m", "h_m", "k_m", "rd_flg")
|
||||
indices = {name: names.index(name) for name in required}
|
||||
values: dict[str, list[str]] = {name: [] for name in required}
|
||||
|
||||
with path.open(encoding="ascii") as table:
|
||||
for line_number, line in enumerate(table):
|
||||
if line_number < first_data_line or not line.strip():
|
||||
continue
|
||||
for name, index in indices.items():
|
||||
left, right = slices[index]
|
||||
values[name].append(line[left:right].strip())
|
||||
|
||||
result: dict[str, np.ndarray] = {}
|
||||
for name in ("ra", "dec", "j_m", "h_m", "k_m"):
|
||||
result[name] = np.array(
|
||||
[float(value) if value else math.nan for value in values[name]], dtype=float
|
||||
)
|
||||
result["rd_flg"] = np.array(values["rd_flg"], dtype="U3")
|
||||
return result
|
||||
|
||||
|
||||
def log_planck_nu_jy_per_sr(log_temperature: np.ndarray) -> np.ndarray:
|
||||
"""Evaluate log B_nu in Jy/sr at the three 2MASS effective wavelengths."""
|
||||
temperature = np.exp(log_temperature)[:, None]
|
||||
frequency = LIGHT_C / WAVELENGTH_M
|
||||
exponent = PLANCK_H * frequency / (BOLTZMANN_K * temperature)
|
||||
radiance_si = 2.0 * PLANCK_H * frequency**3 / LIGHT_C**2 / np.expm1(exponent)
|
||||
return np.log(radiance_si / 1e-26)
|
||||
|
||||
|
||||
def fit_blackbody(log_flux_jy: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Unweighted least-squares fit in log F_nu for T and apparent solid angle."""
|
||||
count = len(log_flux_jy)
|
||||
lower = np.full(count, math.log(MIN_TEMPERATURE_K))
|
||||
upper = np.full(count, math.log(MAX_TEMPERATURE_K))
|
||||
golden = (math.sqrt(5.0) - 1.0) / 2.0
|
||||
first = upper - golden * (upper - lower)
|
||||
second = lower + golden * (upper - lower)
|
||||
|
||||
def objective(log_temperature: np.ndarray) -> np.ndarray:
|
||||
model = log_planck_nu_jy_per_sr(log_temperature)
|
||||
residual = (log_flux_jy - log_flux_jy.mean(axis=1, keepdims=True)
|
||||
- (model - model.mean(axis=1, keepdims=True)))
|
||||
return np.sum(residual * residual, axis=1)
|
||||
|
||||
first_value = objective(first)
|
||||
second_value = objective(second)
|
||||
for _ in range(64):
|
||||
keep_left = first_value <= second_value
|
||||
upper = np.where(keep_left, second, upper)
|
||||
second = np.where(keep_left, first, second)
|
||||
second_value = np.where(keep_left, first_value, second_value)
|
||||
lower = np.where(keep_left, lower, first)
|
||||
first = np.where(keep_left, upper - golden * (upper - lower), second)
|
||||
first_value = np.where(keep_left, objective(first), second_value)
|
||||
second = np.where(keep_left, second, lower + golden * (upper - lower))
|
||||
second_value = np.where(keep_left, second_value, objective(second))
|
||||
|
||||
log_temperature = 0.5 * (lower + upper)
|
||||
log_radiance = log_planck_nu_jy_per_sr(log_temperature)
|
||||
log_solid_angle = np.mean(log_flux_jy - log_radiance, axis=1)
|
||||
return np.exp(log_temperature), np.exp(log_solid_angle)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fit a renderer-facing blackbody catalog from a 2MASS PSC IPAC table."
|
||||
)
|
||||
parser.add_argument("--input", type=Path, default=INPUT,
|
||||
help="raw 2MASS PSC IPAC table")
|
||||
parser.add_argument("--output", type=Path, default=OUTPUT,
|
||||
help="renderer-facing CSV to write")
|
||||
args = parser.parse_args()
|
||||
columns = load_required_columns(args.input)
|
||||
magnitudes = np.column_stack((columns["j_m"], columns["h_m"], columns["k_m"]))
|
||||
valid_photometry = np.isfinite(magnitudes).all(axis=1)
|
||||
valid_rd_flag = np.array(
|
||||
[len(flag) == 3 and all(value in GOOD_RD_FLAGS for value in flag)
|
||||
for flag in columns["rd_flg"]]
|
||||
)
|
||||
keep = valid_photometry & valid_rd_flag
|
||||
if not np.any(keep):
|
||||
raise ValueError("no sources retain valid J/H/Ks photometry")
|
||||
|
||||
flux_jy = ZERO_POINT_JY * np.power(10.0, -0.4 * magnitudes[keep])
|
||||
temperature_k, amplitude_sr = fit_blackbody(np.log(flux_jy))
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.output.open("w", newline="", encoding="ascii") as output:
|
||||
writer = csv.writer(output, lineterminator="\n")
|
||||
writer.writerow(("ra_deg", "dec_deg", "temperature_K", "amplitude_sr"))
|
||||
for ra, dec, temperature, amplitude in zip(
|
||||
columns["ra"][keep], columns["dec"][keep], temperature_k, amplitude_sr
|
||||
):
|
||||
writer.writerow((f"{ra:.6f}", f"{dec:.6f}", f"{temperature:.8g}",
|
||||
f"{amplitude:.9e}"))
|
||||
|
||||
print(f"input_rows={len(keep)}")
|
||||
print(f"discarded_invalid_photometry={np.count_nonzero(~valid_photometry)}")
|
||||
print(f"discarded_rd_flg_not_123={np.count_nonzero(~valid_rd_flag)}")
|
||||
print(f"retained_rows={np.count_nonzero(keep)}")
|
||||
print(f"wrote={args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
#include "catalog.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define PI 3.14159265358979323846
|
||||
|
||||
enum {
|
||||
TEST_GRID_LINE_DEG = 10,
|
||||
TEST_GRID_SAMPLE_DEG = 2,
|
||||
TEST_GRID_RED_TEMPERATURE_K = 3000,
|
||||
TEST_GRID_BLUE_TEMPERATURE_K = 12000,
|
||||
};
|
||||
|
||||
/*
|
||||
* With the renderer's 380--780 nm CIE integration and linear-sRGB luminance,
|
||||
* B(3000 K) / B(12000 K) = 0.00141095580387. Red stars retain unit scale.
|
||||
*/
|
||||
#define TEST_GRID_BLUE_AMPLITUDE 0.00141095580387
|
||||
|
||||
static int test_grid_temperature_K(int longitude_deg, int latitude_deg)
|
||||
{
|
||||
/* Boundaries belong to the octant immediately east/north of them. */
|
||||
const int longitude_sector = longitude_deg / 90;
|
||||
const int hemisphere = latitude_deg < 0 ? 0 : 1;
|
||||
/* Add the hemisphere bit to flip the color across the equator. */
|
||||
const int octant = hemisphere + longitude_sector;
|
||||
return octant % 2 == 0 ? TEST_GRID_RED_TEMPERATURE_K
|
||||
: TEST_GRID_BLUE_TEMPERATURE_K;
|
||||
}
|
||||
|
||||
static double test_grid_amplitude(int temperature_K)
|
||||
{
|
||||
return temperature_K == TEST_GRID_BLUE_TEMPERATURE_K
|
||||
? TEST_GRID_BLUE_AMPLITUDE
|
||||
: 1.0;
|
||||
}
|
||||
|
||||
int catalog_write_octant_grid(const char *path)
|
||||
{
|
||||
FILE *file = fopen(path, "w");
|
||||
if (file == NULL) return -1;
|
||||
fputs("longitude_deg,latitude_deg,temperature_K,amplitude\n", file);
|
||||
for (int latitude = -90; latitude <= 90; latitude += TEST_GRID_SAMPLE_DEG) {
|
||||
for (int longitude = 0; longitude < 360;
|
||||
longitude += TEST_GRID_SAMPLE_DEG) {
|
||||
const int on_longitude_line = longitude % TEST_GRID_LINE_DEG == 0;
|
||||
const int on_latitude_line = latitude % TEST_GRID_LINE_DEG == 0;
|
||||
if ((!on_longitude_line && !on_latitude_line) ||
|
||||
((latitude == -90 || latitude == 90) && longitude != 0))
|
||||
continue;
|
||||
const int temperature_K =
|
||||
test_grid_temperature_K(longitude, latitude);
|
||||
fprintf(file, "%d,%d,%d,%.12g\n", longitude, latitude,
|
||||
temperature_K, test_grid_amplitude(temperature_K));
|
||||
}
|
||||
}
|
||||
return fclose(file) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int catalog_load_csv(StarCatalog *catalog, const char *path)
|
||||
{
|
||||
FILE *file = fopen(path, "r");
|
||||
char line[256];
|
||||
size_t capacity = 0;
|
||||
catalog->stars = NULL;
|
||||
catalog->count = 0;
|
||||
if (file == NULL || fgets(line, sizeof line, file) == NULL) goto fail;
|
||||
|
||||
while (fgets(line, sizeof line, file) != NULL) {
|
||||
double longitude, latitude, temperature, amplitude;
|
||||
if (sscanf(line, "%lf,%lf,%lf,%lf", &longitude, &latitude,
|
||||
&temperature, &litude) != 4)
|
||||
goto fail;
|
||||
if (catalog->count == capacity) {
|
||||
size_t next = capacity == 0 ? 256 : capacity * 2;
|
||||
Star *stars = realloc(catalog->stars, next * sizeof *stars);
|
||||
if (stars == NULL) goto fail;
|
||||
catalog->stars = stars;
|
||||
capacity = next;
|
||||
}
|
||||
const double lon = longitude * PI / 180.0;
|
||||
const double lat = latitude * PI / 180.0;
|
||||
const double cos_lat = cos(lat);
|
||||
Star *star = &catalog->stars[catalog->count++];
|
||||
star->direction[0] = cos_lat * cos(lon);
|
||||
star->direction[1] = sin(lat);
|
||||
star->direction[2] = cos_lat * sin(lon);
|
||||
star->temperature_K = temperature;
|
||||
star->amplitude = amplitude;
|
||||
}
|
||||
fclose(file);
|
||||
return 0;
|
||||
fail:
|
||||
if (file != NULL) fclose(file);
|
||||
catalog_destroy(catalog);
|
||||
return -1;
|
||||
}
|
||||
|
||||
void catalog_destroy(StarCatalog *catalog)
|
||||
{
|
||||
free(catalog->stars);
|
||||
catalog->stars = NULL;
|
||||
catalog->count = 0;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#ifndef CATALOG_H
|
||||
#define CATALOG_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct {
|
||||
double direction[3];
|
||||
double temperature_K;
|
||||
double amplitude;
|
||||
} Star;
|
||||
|
||||
typedef struct {
|
||||
Star *stars;
|
||||
size_t count;
|
||||
} StarCatalog;
|
||||
|
||||
/*
|
||||
* Synthetic lensing fixture: stars lie on the union of 10-degree longitude
|
||||
* and latitude lines, sampled every 2 degrees. The eight longitude/hemisphere
|
||||
* octants alternate between red and blue blackbody temperatures. Blue stars'
|
||||
* scale compensates for their greater visible blackbody radiance.
|
||||
*/
|
||||
int catalog_write_octant_grid(const char *path);
|
||||
int catalog_load_csv(StarCatalog *catalog, const char *path);
|
||||
void catalog_destroy(StarCatalog *catalog);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,378 @@
|
||||
#include "frame.h"
|
||||
|
||||
#include "optics.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <math.h>
|
||||
#include <omp.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Private HDR buffers make splatting independent across workers. This is an
|
||||
* allocation cap, not a rendering parameter: callers transparently fall back
|
||||
* to the serial implementation when the frame is too large for two buffers. */
|
||||
#define FRAME_SPLAT_MAX_PRIVATE_HDR_BYTES ((size_t)512 * 1024 * 1024)
|
||||
|
||||
static const double pi = 3.14159265358979323846;
|
||||
|
||||
static double dot(const double a[3], const double b[3]) {
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
}
|
||||
|
||||
static void cross(const double a[3], const double b[3], double out[3]) {
|
||||
out[0] = a[1] * b[2] - a[2] * b[1];
|
||||
out[1] = a[2] * b[0] - a[0] * b[2];
|
||||
out[2] = a[0] * b[1] - a[1] * b[0];
|
||||
}
|
||||
|
||||
static double normalize(double vector[3]) {
|
||||
const double length = sqrt(dot(vector, vector));
|
||||
if (length > 0.0)
|
||||
for (int i = 0; i < 3; ++i)
|
||||
vector[i] /= length;
|
||||
return length;
|
||||
}
|
||||
|
||||
static size_t vertex_index(int column, int row, int columns) {
|
||||
return (size_t)row * (columns + 1) + column;
|
||||
}
|
||||
|
||||
int frame_lens_mesh_build_coarse(FrameLensMesh *mesh, int width, int height,
|
||||
int cell_pixels, double horizontal_fov_deg) {
|
||||
if (mesh == NULL || width <= 0 || height <= 0 || cell_pixels <= 0 ||
|
||||
horizontal_fov_deg <= 0.0 || horizontal_fov_deg >= 179.0)
|
||||
return -1;
|
||||
const int columns = (width + cell_pixels - 1) / cell_pixels;
|
||||
const int rows = (height + cell_pixels - 1) / cell_pixels;
|
||||
const size_t vertex_count = (size_t)(columns + 1) * (rows + 1);
|
||||
const size_t triangle_count = (size_t)columns * rows * 2;
|
||||
LensVertex *vertices = calloc(vertex_count, sizeof *vertices);
|
||||
LensTriangle *triangles = malloc(triangle_count * sizeof *triangles);
|
||||
if (vertices == NULL || triangles == NULL) {
|
||||
free(vertices);
|
||||
free(triangles);
|
||||
return -1;
|
||||
}
|
||||
const double tan_half_x = tan(horizontal_fov_deg * pi / 360.0);
|
||||
const double tan_half_y = tan_half_x * (double)height / width;
|
||||
for (int row = 0; row <= rows; ++row) {
|
||||
const double image_y = (double)row * height / rows;
|
||||
for (int column = 0; column <= columns; ++column) {
|
||||
LensVertex *vertex = &vertices[vertex_index(column, row, columns)];
|
||||
vertex->image_x = (double)column * width / columns;
|
||||
vertex->image_y = image_y;
|
||||
vertex->camera_direction[0] = 1.0;
|
||||
vertex->camera_direction[1] = (0.5 - image_y / height) * 2.0 * tan_half_y;
|
||||
vertex->camera_direction[2] =
|
||||
(vertex->image_x / width - 0.5) * 2.0 * tan_half_x;
|
||||
normalize(vertex->camera_direction);
|
||||
}
|
||||
}
|
||||
size_t next_triangle = 0;
|
||||
for (int row = 0; row < rows; ++row)
|
||||
for (int column = 0; column < columns; ++column) {
|
||||
const size_t top_left = vertex_index(column, row, columns);
|
||||
const size_t top_right = vertex_index(column + 1, row, columns);
|
||||
const size_t bottom_left = vertex_index(column, row + 1, columns);
|
||||
const size_t bottom_right = vertex_index(column + 1, row + 1, columns);
|
||||
triangles[next_triangle++] =
|
||||
(LensTriangle){{top_left, bottom_left, bottom_right}};
|
||||
triangles[next_triangle++] =
|
||||
(LensTriangle){{top_left, bottom_right, top_right}};
|
||||
}
|
||||
*mesh = (FrameLensMesh){.vertices = vertices,
|
||||
.triangles = triangles,
|
||||
.vertex_count = vertex_count,
|
||||
.triangle_count = triangle_count};
|
||||
return 0;
|
||||
}
|
||||
|
||||
int frame_lens_mesh_trace(FrameLensMesh *mesh, const SpacetimeSource *spacetime,
|
||||
const ObserverState *observer,
|
||||
const GeodesicTraceConfig *trace) {
|
||||
if (mesh == NULL || spacetime == NULL || observer == NULL || trace == NULL)
|
||||
return -1;
|
||||
/* Each iteration exclusively owns one vertex. SpacetimeSource is shared
|
||||
* read-only here; backends with mutable evaluation state must keep it
|
||||
* thread-local. */
|
||||
#pragma omp parallel for schedule(static)
|
||||
for (size_t i = 0; i < mesh->vertex_count; ++i) {
|
||||
LensVertex *vertex = &mesh->vertices[i];
|
||||
RayEndpoint endpoint = geodesic_trace_past(spacetime, observer,
|
||||
vertex->camera_direction, trace);
|
||||
vertex->status = endpoint.status;
|
||||
if (endpoint.status == RAY_ENDPOINT_ESCAPED) {
|
||||
for (int axis = 0; axis < 3; ++axis)
|
||||
vertex->n_infinity[axis] = endpoint.n_infinity[axis];
|
||||
vertex->log_frequency_ratio = log(endpoint.frequency_ratio);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static double spherical_area(const double a[3], const double b[3],
|
||||
const double c[3]) {
|
||||
double b_cross_c[3];
|
||||
cross(b, c, b_cross_c);
|
||||
return 2.0 * atan2(fabs(dot(a, b_cross_c)),
|
||||
1.0 + dot(a, b) + dot(b, c) + dot(c, a));
|
||||
}
|
||||
|
||||
static int spherical_barycentric(const double point[3], const double a[3],
|
||||
const double b[3], const double c[3],
|
||||
double weights[3]) {
|
||||
const double area = spherical_area(a, b, c);
|
||||
double edge_cross[3];
|
||||
if (area < 1e-14)
|
||||
return -1;
|
||||
const double *corners[3] = {a, b, c};
|
||||
for (int edge = 0; edge < 3; ++edge) {
|
||||
const double *left = corners[edge];
|
||||
const double *right = corners[(edge + 1) % 3];
|
||||
const double *opposite = corners[(edge + 2) % 3];
|
||||
cross(left, right, edge_cross);
|
||||
/* This is a sign test, so its tolerance must scale with the source
|
||||
* triangle. A fixed absolute threshold turns sufficiently fine triangles
|
||||
* into near-all-sky queries. */
|
||||
if (dot(edge_cross, point) * dot(edge_cross, opposite) <
|
||||
-1e-14 * dot(edge_cross, edge_cross))
|
||||
return -1;
|
||||
}
|
||||
weights[0] = spherical_area(point, b, c) / area;
|
||||
weights[1] = spherical_area(point, c, a) / area;
|
||||
weights[2] = spherical_area(point, a, b) / area;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int usable_triangle(const FrameLensMesh *mesh,
|
||||
const LensTriangle *triangle,
|
||||
const LensVertex *vertices[3]) {
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
vertices[i] = &mesh->vertices[triangle->vertex[i]];
|
||||
if (vertices[i]->status != RAY_ENDPOINT_ESCAPED)
|
||||
return 0;
|
||||
}
|
||||
return spherical_area(vertices[0]->n_infinity, vertices[1]->n_infinity,
|
||||
vertices[2]->n_infinity) >= 1e-14;
|
||||
}
|
||||
|
||||
/* Give a source lying exactly on a shared source edge to one triangle only.
|
||||
* Interior overlaps remain valid separate lens images. */
|
||||
static int owns_source_boundary(const LensTriangle *triangle,
|
||||
const double weights[3]) {
|
||||
for (int opposite = 0; opposite < 3; ++opposite) {
|
||||
if (weights[opposite] > 1e-11)
|
||||
continue;
|
||||
const size_t left = triangle->vertex[(opposite + 1) % 3];
|
||||
const size_t right = triangle->vertex[(opposite + 2) % 3];
|
||||
if (left > right)
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static size_t splat_catalog_triangles(const FrameLensMesh *mesh,
|
||||
const StarCatalog *catalog, double *hdr,
|
||||
int width, int height, double exposure,
|
||||
const PointSpreadFunction *psf,
|
||||
size_t first_triangle,
|
||||
size_t last_triangle) {
|
||||
size_t images = 0;
|
||||
for (size_t t = first_triangle; t < last_triangle; ++t) {
|
||||
const LensVertex *vertex[3];
|
||||
if (!usable_triangle(mesh, &mesh->triangles[t], vertex))
|
||||
continue;
|
||||
const double source_area = spherical_area(
|
||||
vertex[0]->n_infinity, vertex[1]->n_infinity, vertex[2]->n_infinity);
|
||||
const double image_area =
|
||||
spherical_area(vertex[0]->camera_direction, vertex[1]->camera_direction,
|
||||
vertex[2]->camera_direction);
|
||||
const double magnification = image_area / source_area;
|
||||
for (size_t s = 0; s < catalog->count; ++s) {
|
||||
const Star *star = &catalog->stars[s];
|
||||
double weights[3];
|
||||
if (spherical_barycentric(star->direction, vertex[0]->n_infinity,
|
||||
vertex[1]->n_infinity, vertex[2]->n_infinity,
|
||||
weights))
|
||||
continue;
|
||||
if (!owns_source_boundary(&mesh->triangles[t], weights))
|
||||
continue;
|
||||
const double image_x = weights[0] * vertex[0]->image_x +
|
||||
weights[1] * vertex[1]->image_x +
|
||||
weights[2] * vertex[2]->image_x;
|
||||
const double image_y = weights[0] * vertex[0]->image_y +
|
||||
weights[1] * vertex[1]->image_y +
|
||||
weights[2] * vertex[2]->image_y;
|
||||
const double log_g = weights[0] * vertex[0]->log_frequency_ratio +
|
||||
weights[1] * vertex[1]->log_frequency_ratio +
|
||||
weights[2] * vertex[2]->log_frequency_ratio;
|
||||
const LinearRgb color =
|
||||
blackbody_to_linear_rgb(star->temperature_K * exp(log_g));
|
||||
splat_moffat(hdr, width, height, image_x, image_y, color,
|
||||
exposure * star->amplitude * magnification, psf);
|
||||
++images;
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
size_t frame_splat_catalog(const FrameLensMesh *mesh,
|
||||
const StarCatalog *catalog, double *hdr, int width,
|
||||
int height, double exposure,
|
||||
const PointSpreadFunction *psf) {
|
||||
if (mesh == NULL || catalog == NULL || hdr == NULL || exposure <= 0.0 ||
|
||||
psf == NULL || width <= 0 || height <= 0)
|
||||
return 0;
|
||||
|
||||
const size_t pixel_count = (size_t)width * height * 3;
|
||||
if (pixel_count > SIZE_MAX / sizeof(double) ||
|
||||
pixel_count * sizeof(double) > FRAME_SPLAT_MAX_PRIVATE_HDR_BYTES / 2)
|
||||
return splat_catalog_triangles(mesh, catalog, hdr, width, height, exposure,
|
||||
psf, 0, mesh->triangle_count);
|
||||
const size_t buffer_bytes = pixel_count * sizeof(double);
|
||||
size_t worker_count = FRAME_SPLAT_MAX_PRIVATE_HDR_BYTES / buffer_bytes;
|
||||
const int max_threads = omp_get_max_threads();
|
||||
if (worker_count > (size_t)max_threads)
|
||||
worker_count = (size_t)max_threads;
|
||||
if (worker_count < 2 || worker_count > INT_MAX)
|
||||
return splat_catalog_triangles(mesh, catalog, hdr, width, height, exposure,
|
||||
psf, 0, mesh->triangle_count);
|
||||
|
||||
double **private_hdr = calloc(worker_count, sizeof *private_hdr);
|
||||
if (private_hdr == NULL)
|
||||
return splat_catalog_triangles(mesh, catalog, hdr, width, height, exposure,
|
||||
psf, 0, mesh->triangle_count);
|
||||
size_t allocated = 0;
|
||||
for (; allocated < worker_count; ++allocated) {
|
||||
private_hdr[allocated] = calloc(pixel_count, sizeof **private_hdr);
|
||||
if (private_hdr[allocated] == NULL)
|
||||
break;
|
||||
}
|
||||
if (allocated != worker_count) {
|
||||
while (allocated > 0)
|
||||
free(private_hdr[--allocated]);
|
||||
free(private_hdr);
|
||||
return splat_catalog_triangles(mesh, catalog, hdr, width, height, exposure,
|
||||
psf, 0, mesh->triangle_count);
|
||||
}
|
||||
|
||||
size_t images = 0;
|
||||
#pragma omp parallel num_threads((int)worker_count) reduction(+ : images)
|
||||
{
|
||||
const size_t worker = (size_t)omp_get_thread_num();
|
||||
const size_t first = mesh->triangle_count * worker / worker_count;
|
||||
const size_t last = mesh->triangle_count * (worker + 1) / worker_count;
|
||||
images += splat_catalog_triangles(mesh, catalog, private_hdr[worker], width,
|
||||
height, exposure, psf, first, last);
|
||||
}
|
||||
#pragma omp parallel for schedule(static)
|
||||
for (size_t pixel = 0; pixel < pixel_count; ++pixel)
|
||||
for (size_t worker = 0; worker < worker_count; ++worker)
|
||||
hdr[pixel] += private_hdr[worker][pixel];
|
||||
for (size_t worker = 0; worker < worker_count; ++worker)
|
||||
free(private_hdr[worker]);
|
||||
free(private_hdr);
|
||||
return images;
|
||||
}
|
||||
|
||||
static void blend_gray(double *hdr, int width, int height, int x, int y,
|
||||
double gray, double alpha) {
|
||||
if (x < 0 || x >= width || y < 0 || y >= height)
|
||||
return;
|
||||
double *pixel = &hdr[3 * (y * width + x)];
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
pixel[channel] = (1.0 - alpha) * pixel[channel] + alpha * gray;
|
||||
}
|
||||
|
||||
static double fractional_part(double value) { return value - floor(value); }
|
||||
|
||||
static void plot_aa(double *hdr, int width, int height, int steep, int x, int y,
|
||||
double coverage, double gray, double opacity) {
|
||||
if (coverage > 0.0)
|
||||
blend_gray(hdr, width, height, steep ? y : x, steep ? x : y, gray,
|
||||
coverage * opacity);
|
||||
}
|
||||
|
||||
/* Xiaolin Wu line rasterization: a one-pixel line with coverage-based alpha. */
|
||||
static void draw_line(double *hdr, int width, int height,
|
||||
const LensVertex *from, const LensVertex *to, double gray,
|
||||
double opacity) {
|
||||
double x0 = from->image_x, y0 = from->image_y;
|
||||
double x1 = to->image_x, y1 = to->image_y;
|
||||
const int steep = fabs(y1 - y0) > fabs(x1 - x0);
|
||||
if (steep) {
|
||||
double swap = x0;
|
||||
x0 = y0;
|
||||
y0 = swap;
|
||||
swap = x1;
|
||||
x1 = y1;
|
||||
y1 = swap;
|
||||
}
|
||||
if (x0 > x1) {
|
||||
double swap = x0;
|
||||
x0 = x1;
|
||||
x1 = swap;
|
||||
swap = y0;
|
||||
y0 = y1;
|
||||
y1 = swap;
|
||||
}
|
||||
const double dx = x1 - x0;
|
||||
if (dx == 0.0) {
|
||||
plot_aa(hdr, width, height, steep, (int)lround(x0), (int)floor(y0), 1.0,
|
||||
gray, opacity);
|
||||
return;
|
||||
}
|
||||
const double gradient = (y1 - y0) / dx;
|
||||
double x_end = round(x0);
|
||||
double y_end = y0 + gradient * (x_end - x0);
|
||||
double x_gap = 1.0 - fractional_part(x0 + 0.5);
|
||||
int x_pixel_start = (int)x_end;
|
||||
int y_pixel = (int)floor(y_end);
|
||||
plot_aa(hdr, width, height, steep, x_pixel_start, y_pixel,
|
||||
(1.0 - fractional_part(y_end)) * x_gap, gray, opacity);
|
||||
plot_aa(hdr, width, height, steep, x_pixel_start, y_pixel + 1,
|
||||
fractional_part(y_end) * x_gap, gray, opacity);
|
||||
double inter_y = y_end + gradient;
|
||||
x_end = round(x1);
|
||||
y_end = y1 + gradient * (x_end - x1);
|
||||
x_gap = fractional_part(x1 + 0.5);
|
||||
const int x_pixel_end = (int)x_end;
|
||||
y_pixel = (int)floor(y_end);
|
||||
plot_aa(hdr, width, height, steep, x_pixel_end, y_pixel,
|
||||
(1.0 - fractional_part(y_end)) * x_gap, gray, opacity);
|
||||
plot_aa(hdr, width, height, steep, x_pixel_end, y_pixel + 1,
|
||||
fractional_part(y_end) * x_gap, gray, opacity);
|
||||
for (int x = x_pixel_start + 1; x < x_pixel_end; ++x) {
|
||||
y_pixel = (int)floor(inter_y);
|
||||
plot_aa(hdr, width, height, steep, x, y_pixel,
|
||||
1.0 - fractional_part(inter_y), gray, opacity);
|
||||
plot_aa(hdr, width, height, steep, x, y_pixel + 1, fractional_part(inter_y),
|
||||
gray, opacity);
|
||||
inter_y += gradient;
|
||||
}
|
||||
}
|
||||
|
||||
void frame_draw_mesh(const FrameLensMesh *mesh, double *hdr, int width,
|
||||
int height, double gray, double opacity) {
|
||||
if (mesh == NULL || hdr == NULL || width <= 0 || height <= 0 || gray < 0.0 ||
|
||||
opacity < 0.0 || opacity > 1.0)
|
||||
return;
|
||||
for (size_t i = 0; i < mesh->triangle_count; ++i) {
|
||||
const LensTriangle *triangle = &mesh->triangles[i];
|
||||
for (int edge = 0; edge < 3; ++edge) {
|
||||
const size_t from_id = triangle->vertex[edge];
|
||||
const size_t to_id = triangle->vertex[(edge + 1) % 3];
|
||||
if (from_id < to_id)
|
||||
draw_line(hdr, width, height, &mesh->vertices[from_id],
|
||||
&mesh->vertices[to_id], gray, opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void frame_lens_mesh_destroy(FrameLensMesh *mesh) {
|
||||
if (mesh == NULL)
|
||||
return;
|
||||
free(mesh->vertices);
|
||||
free(mesh->triangles);
|
||||
*mesh = (FrameLensMesh){0};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#ifndef FRAME_H
|
||||
#define FRAME_H
|
||||
|
||||
#include "catalog.h"
|
||||
#include "geodesic.h"
|
||||
#include "optics.h"
|
||||
#include "observer.h"
|
||||
#include "spacetime.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct {
|
||||
double image_x, image_y;
|
||||
double camera_direction[3];
|
||||
double n_infinity[3];
|
||||
double log_frequency_ratio;
|
||||
RayEndpointStatus status;
|
||||
} LensVertex;
|
||||
|
||||
typedef struct {
|
||||
size_t vertex[3];
|
||||
} LensTriangle;
|
||||
|
||||
typedef struct {
|
||||
LensVertex *vertices;
|
||||
LensTriangle *triangles;
|
||||
size_t vertex_count, triangle_count;
|
||||
} FrameLensMesh;
|
||||
|
||||
int frame_lens_mesh_build_coarse(FrameLensMesh *mesh, int width, int height,
|
||||
int cell_pixels, double horizontal_fov_deg);
|
||||
int frame_lens_mesh_trace(FrameLensMesh *mesh, const SpacetimeSource *spacetime,
|
||||
const ObserverState *observer,
|
||||
const GeodesicTraceConfig *trace);
|
||||
|
||||
/* Each locally invertible escaped triangle contributes one image per contained
|
||||
* star. */
|
||||
size_t frame_splat_catalog(const FrameLensMesh *mesh,
|
||||
const StarCatalog *catalog, double *hdr, int width,
|
||||
int height, double exposure,
|
||||
const PointSpreadFunction *psf);
|
||||
void frame_draw_mesh(const FrameLensMesh *mesh, double *hdr, int width,
|
||||
int height, double gray, double opacity);
|
||||
void frame_lens_mesh_destroy(FrameLensMesh *mesh);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,189 @@
|
||||
#include "geodesic.h"
|
||||
#include <math.h>
|
||||
|
||||
typedef struct {
|
||||
double x[3], Pi[3], log_alpha_p0;
|
||||
} State;
|
||||
typedef struct {
|
||||
double x[3], Pi[3], log_alpha_p0;
|
||||
} Derivative;
|
||||
|
||||
static double dot(const double a[3], const double b[3]) {
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
}
|
||||
|
||||
static int invert(double a[3][3], double b[3][3]) {
|
||||
double d = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) -
|
||||
a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) +
|
||||
a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
|
||||
if (!isfinite(d) || fabs(d) < 1e-14)
|
||||
return -1;
|
||||
b[0][0] = (a[1][1] * a[2][2] - a[1][2] * a[2][1]) / d;
|
||||
b[0][1] = (a[0][2] * a[2][1] - a[0][1] * a[2][2]) / d;
|
||||
b[0][2] = (a[0][1] * a[1][2] - a[0][2] * a[1][1]) / d;
|
||||
b[1][0] = (a[1][2] * a[2][0] - a[1][0] * a[2][2]) / d;
|
||||
b[1][1] = (a[0][0] * a[2][2] - a[0][2] * a[2][0]) / d;
|
||||
b[1][2] = (a[0][2] * a[1][0] - a[0][0] * a[1][2]) / d;
|
||||
b[2][0] = (a[1][0] * a[2][1] - a[1][1] * a[2][0]) / d;
|
||||
b[2][1] = (a[0][1] * a[2][0] - a[0][0] * a[2][1]) / d;
|
||||
b[2][2] = (a[0][0] * a[1][1] - a[0][1] * a[1][0]) / d;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Equation (4) and (5) of Bohn et al., arXiv:1410.7775. */
|
||||
static int rhs(const SpacetimeSource *source, double t, const State *s,
|
||||
Derivative *out) {
|
||||
MetricData m;
|
||||
double inv[3][3], up[3] = {0}, da_pi = 0, k_pi_pi = 0;
|
||||
if (spacetime_eval(source, t, s->x, &m) || m.alpha <= 0 ||
|
||||
invert(m.gamma, inv))
|
||||
return -1;
|
||||
for (int i = 0; i < 3; i++)
|
||||
for (int j = 0; j < 3; j++)
|
||||
up[i] += inv[i][j] * s->Pi[j];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
da_pi += m.d_alpha[i] * up[i];
|
||||
for (int j = 0; j < 3; j++)
|
||||
k_pi_pi += m.K[i][j] * up[i] * up[j];
|
||||
}
|
||||
for (int i = 0; i < 3; i++) {
|
||||
double db_pi = 0, dg_pi_pi = 0;
|
||||
out->x[i] = m.alpha * up[i] - m.beta[i];
|
||||
for (int j = 0; j < 3; j++) {
|
||||
db_pi += m.d_beta[i][j] * s->Pi[j];
|
||||
for (int k = 0; k < 3; k++) {
|
||||
/* Equation (4) requires partial_i gamma^{jk}, while MetricData
|
||||
* deliberately stores partial_i gamma_jk because that is what metric
|
||||
* backends interpolate naturally. Differentiate gamma^{-1}:
|
||||
* partial_i gamma^{jk} = -gamma^{ja}(partial_i gamma_ab)gamma^{bk}.
|
||||
*/
|
||||
double d_inverse_gamma = 0.0;
|
||||
for (int a = 0; a < 3; ++a)
|
||||
for (int b = 0; b < 3; ++b)
|
||||
d_inverse_gamma -= inv[j][a] * m.d_gamma[i][a][b] * inv[b][k];
|
||||
dg_pi_pi += d_inverse_gamma * s->Pi[j] * s->Pi[k];
|
||||
}
|
||||
}
|
||||
out->Pi[i] = -m.d_alpha[i] + (da_pi - m.alpha * k_pi_pi) * s->Pi[i] +
|
||||
db_pi - 0.5 * m.alpha * dg_pi_pi;
|
||||
}
|
||||
out->log_alpha_p0 = -da_pi + m.alpha * k_pi_pi;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static State add(const State *s, const Derivative *d, double h) {
|
||||
State r = *s;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
r.x[i] += h * d->x[i];
|
||||
r.Pi[i] += h * d->Pi[i];
|
||||
}
|
||||
r.log_alpha_p0 += h * d->log_alpha_p0;
|
||||
return r;
|
||||
}
|
||||
|
||||
static int rk4(const SpacetimeSource *source, double t, double h, State *s) {
|
||||
Derivative a, b, c, d;
|
||||
State q;
|
||||
if (rhs(source, t, s, &a))
|
||||
return -1;
|
||||
q = add(s, &a, h / 2);
|
||||
if (rhs(source, t + h / 2, &q, &b))
|
||||
return -1;
|
||||
q = add(s, &b, h / 2);
|
||||
if (rhs(source, t + h / 2, &q, &c))
|
||||
return -1;
|
||||
q = add(s, &c, h);
|
||||
if (rhs(source, t + h, &q, &d))
|
||||
return -1;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
s->x[i] += h * (a.x[i] + 2 * b.x[i] + 2 * c.x[i] + d.x[i]) / 6;
|
||||
s->Pi[i] += h * (a.Pi[i] + 2 * b.Pi[i] + 2 * c.Pi[i] + d.Pi[i]) / 6;
|
||||
}
|
||||
s->log_alpha_p0 += h *
|
||||
(a.log_alpha_p0 + 2 * b.log_alpha_p0 + 2 * c.log_alpha_p0 +
|
||||
d.log_alpha_p0) /
|
||||
6;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int initialize(const SpacetimeSource *source, const ObserverState *o,
|
||||
const double n[3], State *s) {
|
||||
MetricData m;
|
||||
double k[4] = {o->tetrad[0][0], o->tetrad[0][1], o->tetrad[0][2],
|
||||
o->tetrad[0][3]};
|
||||
if (spacetime_eval(source, o->coordinate_time, o->coordinate_position, &m) ||
|
||||
m.alpha <= 0)
|
||||
return -1;
|
||||
for (int a = 0; a < 3; a++)
|
||||
for (int mu = 0; mu < 4; mu++)
|
||||
k[mu] -= n[a] * o->tetrad[a + 1][mu];
|
||||
if (k[0] <= 0)
|
||||
return -1;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
s->x[i] = o->coordinate_position[i];
|
||||
s->Pi[i] = 0;
|
||||
for (int j = 0; j < 3; j++)
|
||||
s->Pi[i] += m.gamma[i][j] * (k[j + 1] + m.beta[j] * k[0]);
|
||||
s->Pi[i] /= m.alpha * k[0];
|
||||
}
|
||||
s->log_alpha_p0 = log(m.alpha * k[0]);
|
||||
return isfinite(s->log_alpha_p0) ? 0 : -1;
|
||||
}
|
||||
|
||||
static int escaped_direction(const SpacetimeSource *source, double t,
|
||||
const State *s, double n[3]) {
|
||||
MetricData m;
|
||||
double inv[3][3], norm = 0;
|
||||
if (spacetime_eval(source, t, s->x, &m) || invert(m.gamma, inv))
|
||||
return -1;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
n[i] = 0;
|
||||
for (int j = 0; j < 3; j++)
|
||||
n[i] -= inv[i][j] * s->Pi[j];
|
||||
norm += n[i] * n[i];
|
||||
}
|
||||
if (norm <= 0)
|
||||
return -1;
|
||||
for (int i = 0; i < 3; i++)
|
||||
n[i] /= sqrt(norm);
|
||||
return 0;
|
||||
}
|
||||
|
||||
RayEndpoint geodesic_trace_past(const SpacetimeSource *source,
|
||||
const ObserverState *observer,
|
||||
const double n[3],
|
||||
const GeodesicTraceConfig *config) {
|
||||
RayEndpoint out = {.frequency_ratio = 0,
|
||||
.magnification = 1,
|
||||
.status = RAY_ENDPOINT_INTEGRATION_FAILURE};
|
||||
State s;
|
||||
double t;
|
||||
if (!source || !observer || !config || config->coordinate_time_step <= 0 ||
|
||||
!config->max_steps || fabs(dot(n, n) - 1) > 1e-10 ||
|
||||
initialize(source, observer, n, &s))
|
||||
return out;
|
||||
t = observer->coordinate_time;
|
||||
for (unsigned int i = 0; i < config->max_steps; i++) {
|
||||
if (config->capture_log_alpha_p0 > 0.0 &&
|
||||
s.log_alpha_p0 >= config->capture_log_alpha_p0) {
|
||||
out.status = RAY_ENDPOINT_CAPTURED;
|
||||
return out;
|
||||
}
|
||||
SpacetimeRayStatus status = spacetime_classify(source, t, s.x);
|
||||
if (status != SPACETIME_RAY_ACTIVE) {
|
||||
out.status = status == SPACETIME_RAY_ESCAPED ? RAY_ENDPOINT_ESCAPED
|
||||
: RAY_ENDPOINT_CAPTURED;
|
||||
if (out.status == RAY_ENDPOINT_ESCAPED &&
|
||||
escaped_direction(source, t, &s, out.n_infinity) == 0)
|
||||
out.frequency_ratio = exp(-s.log_alpha_p0);
|
||||
else if (out.status == RAY_ENDPOINT_ESCAPED)
|
||||
out.status = RAY_ENDPOINT_INTEGRATION_FAILURE;
|
||||
return out;
|
||||
}
|
||||
if (rk4(source, t, -config->coordinate_time_step, &s))
|
||||
return out;
|
||||
t -= config->coordinate_time_step;
|
||||
}
|
||||
out.status = RAY_ENDPOINT_MAX_STEPS;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#ifndef GEODESIC_H
|
||||
#define GEODESIC_H
|
||||
|
||||
#include "observer.h"
|
||||
#include "spacetime.h"
|
||||
|
||||
typedef enum {
|
||||
RAY_ENDPOINT_ESCAPED,
|
||||
RAY_ENDPOINT_CAPTURED,
|
||||
RAY_ENDPOINT_MAX_STEPS,
|
||||
RAY_ENDPOINT_INTEGRATION_FAILURE
|
||||
} RayEndpointStatus;
|
||||
|
||||
typedef struct {
|
||||
double n_infinity[3];
|
||||
double frequency_ratio; /* E_camera / E_infinity */
|
||||
double magnification; /* Filled by the future local inverse lens map. */
|
||||
RayEndpointStatus status;
|
||||
} RayEndpoint;
|
||||
|
||||
typedef struct {
|
||||
double coordinate_time_step;
|
||||
unsigned int max_steps;
|
||||
/* A positive value terminates a backwards ray whose horizon redshift has
|
||||
* made log(alpha p^0) reach this value. Zero disables this analytic/demo
|
||||
* criterion; numerical moving-puncture backends use their AH-calibrated
|
||||
* spatial cutoff instead. */
|
||||
double capture_log_alpha_p0;
|
||||
} GeodesicTraceConfig;
|
||||
|
||||
/* camera_direction is a unit vector in the observer's (forward, up, right)
|
||||
* tetrad. */
|
||||
RayEndpoint geodesic_trace_past(const SpacetimeSource *source,
|
||||
const ObserverState *observer,
|
||||
const double camera_direction[3],
|
||||
const GeodesicTraceConfig *config);
|
||||
#endif
|
||||
@@ -0,0 +1,200 @@
|
||||
#include "catalog.h"
|
||||
#include "frame.h"
|
||||
#include "optics.h"
|
||||
#include "spacetime.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
int width, height;
|
||||
int coarse_cell_pixels;
|
||||
int draw_mesh;
|
||||
double horizontal_fov_deg, look_ra_deg, look_dec_deg, exposure;
|
||||
double observer_inward_speed;
|
||||
PointSpreadFunction psf;
|
||||
const char *catalog_path;
|
||||
const char *output_path;
|
||||
} Settings;
|
||||
|
||||
static int parse_int(const char *text, int *value) {
|
||||
char *end;
|
||||
errno = 0;
|
||||
long parsed = strtol(text, &end, 10);
|
||||
if (errno || *end || parsed <= 0 || parsed > 16384)
|
||||
return -1;
|
||||
*value = (int)parsed;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_double(const char *text, double *value) {
|
||||
char *end;
|
||||
errno = 0;
|
||||
*value = strtod(text, &end);
|
||||
return errno || *end || *value <= 0.0 || *value >= 179.0 ? -1 : 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static int parse_positive(const char *text, double *value) {
|
||||
char *end;
|
||||
errno = 0;
|
||||
*value = strtod(text, &end);
|
||||
return errno || *end || *value <= 0.0 ? -1 : 0;
|
||||
}
|
||||
|
||||
static int parse_speed(const char *text, double *value) {
|
||||
char *end;
|
||||
errno = 0;
|
||||
*value = strtod(text, &end);
|
||||
return errno || *end || *value < 0.0 || *value >= 1.0 ? -1 : 0;
|
||||
}
|
||||
|
||||
static int parse_moffat_beta(const char *text, double *value) {
|
||||
char *end;
|
||||
errno = 0;
|
||||
*value = strtod(text, &end);
|
||||
return errno || *end || *value <= 1.0 ? -1 : 0;
|
||||
}
|
||||
|
||||
static int parse_args(int argc, char **argv, Settings *s,
|
||||
const char **write_path) {
|
||||
*s = (Settings){1280,
|
||||
720,
|
||||
16,
|
||||
0,
|
||||
30.0,
|
||||
270.0,
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
{2.7, 4.5},
|
||||
"assets/sky_grid_5deg.csv",
|
||||
"output/imgs/minkowski_sky.ppm"};
|
||||
*write_path = NULL;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (!strcmp(argv[i], "--catalog") && i + 1 < argc)
|
||||
s->catalog_path = argv[++i];
|
||||
else if (!strcmp(argv[i], "--output") && i + 1 < argc)
|
||||
s->output_path = argv[++i];
|
||||
else if (!strcmp(argv[i], "--width") && i + 1 < argc &&
|
||||
!parse_int(argv[++i], &s->width)) {
|
||||
} else if (!strcmp(argv[i], "--height") && i + 1 < argc &&
|
||||
!parse_int(argv[++i], &s->height)) {
|
||||
} else if (!strcmp(argv[i], "--coarse-cell-pixels") && i + 1 < argc &&
|
||||
!parse_int(argv[++i], &s->coarse_cell_pixels)) {
|
||||
} else if (!strcmp(argv[i], "--draw-mesh")) {
|
||||
s->draw_mesh = 1;
|
||||
} else if (!strcmp(argv[i], "--fov-deg") && i + 1 < argc &&
|
||||
!parse_double(argv[++i], &s->horizontal_fov_deg)) {
|
||||
} else if (!strcmp(argv[i], "--look-ra-deg") && i + 1 < argc &&
|
||||
!parse_ra_deg(argv[++i], &s->look_ra_deg)) {
|
||||
} else if (!strcmp(argv[i], "--look-dec-deg") && i + 1 < argc &&
|
||||
!parse_dec_deg(argv[++i], &s->look_dec_deg)) {
|
||||
} 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], "--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 &&
|
||||
!parse_moffat_beta(argv[++i], &s->psf.moffat_beta)) {
|
||||
} else if (!strcmp(argv[i], "--write-catalog") && i + 1 < argc)
|
||||
*write_path = argv[++i];
|
||||
else
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int render_frame(const Settings *s, const StarCatalog *catalog,
|
||||
const SpacetimeSource *spacetime) {
|
||||
#ifdef SPACETIME_SCHWARZSCHILD
|
||||
ObserverState observer;
|
||||
if (observer_inward_schwarzschild_ks(1.0, 30.0,
|
||||
s->observer_inward_speed, &observer))
|
||||
return -1;
|
||||
const GeodesicTraceConfig trace = {.coordinate_time_step = 0.1,
|
||||
.max_steps = 4096,
|
||||
.capture_log_alpha_p0 = 8.0};
|
||||
#else
|
||||
const ObserverState observer =
|
||||
observer_fixed_at_origin_look_at(s->look_ra_deg, s->look_dec_deg);
|
||||
const GeodesicTraceConfig trace = {.coordinate_time_step = 1.0,
|
||||
.max_steps = 2048};
|
||||
#endif
|
||||
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,
|
||||
s->coarse_cell_pixels,
|
||||
s->horizontal_fov_deg) ||
|
||||
frame_lens_mesh_trace(&mesh, spacetime, &observer, &trace)) {
|
||||
frame_lens_mesh_destroy(&mesh);
|
||||
free(hdr);
|
||||
return -1;
|
||||
}
|
||||
size_t images = frame_splat_catalog(&mesh, catalog, hdr, s->width, s->height,
|
||||
s->exposure, &s->psf);
|
||||
if (s->draw_mesh)
|
||||
frame_draw_mesh(&mesh, hdr, s->width, s->height, 0.5, 0.5);
|
||||
int result = write_tonemapped_image(s->output_path, hdr, s->width, s->height);
|
||||
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (%s)\n",
|
||||
images, catalog->count, s->output_path,
|
||||
result == 0 ? "ok" : "write failed");
|
||||
frame_lens_mesh_destroy(&mesh);
|
||||
free(hdr);
|
||||
return result;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
Settings settings;
|
||||
const char *write_path;
|
||||
if (parse_args(argc, argv, &settings, &write_path)) {
|
||||
fprintf(stderr,
|
||||
"Usage: %s [--catalog PATH] [--output PATH] [--width N] [--height "
|
||||
"N] [--fov-deg D] [--look-ra-deg D] [--look-dec-deg D] "
|
||||
"[--exposure E] [--observer-inward-speed V] "
|
||||
"[--psf-fwhm-pixels N] [--psf-moffat-beta N] "
|
||||
"[--coarse-cell-pixels N] [--draw-mesh] "
|
||||
"[--write-catalog PATH]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
if (write_path != NULL)
|
||||
return catalog_write_octant_grid(write_path) == 0 ? 0
|
||||
: (perror(write_path), 1);
|
||||
StarCatalog catalog;
|
||||
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);
|
||||
return 1;
|
||||
}
|
||||
fprintf(stderr, "Created test catalog: %s\n", settings.catalog_path);
|
||||
}
|
||||
SpacetimeSource spacetime = {0};
|
||||
if (spacetime_create_default(&spacetime)) {
|
||||
fputs("Could not create spacetime source\n", stderr);
|
||||
catalog_destroy(&catalog);
|
||||
return 1;
|
||||
}
|
||||
int result = render_frame(&settings, &catalog, &spacetime);
|
||||
spacetime_destroy(&spacetime);
|
||||
catalog_destroy(&catalog);
|
||||
return result == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#include "observer.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stddef.h>
|
||||
|
||||
static const double pi = 3.14159265358979323846;
|
||||
|
||||
ObserverState observer_fixed_at_origin(void) {
|
||||
return (ObserverState){.coordinate_time = 0.0,
|
||||
.coordinate_position = {0.0, 0.0, 0.0},
|
||||
.tetrad = {{1.0, 0.0, 0.0, 0.0},
|
||||
{0.0, 0.0, 0.0, -1.0},
|
||||
{0.0, 0.0, 1.0, 0.0},
|
||||
{0.0, 1.0, 0.0, 0.0}}};
|
||||
}
|
||||
|
||||
ObserverState observer_fixed_at_origin_look_at(double ra_deg, double dec_deg) {
|
||||
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);
|
||||
const double forward[3] = {cos_dec * cos_ra, sin_dec, cos_dec * sin_ra};
|
||||
const double up[3] = {-sin_dec * cos_ra, cos_dec, -sin_dec * sin_ra};
|
||||
const double right[3] = {-sin_ra, 0.0, cos_ra};
|
||||
return (ObserverState){.coordinate_time = 0.0,
|
||||
.coordinate_position = {0.0, 0.0, 0.0},
|
||||
.tetrad = {{1.0, 0.0, 0.0, 0.0},
|
||||
{0.0, forward[0], forward[1], forward[2]},
|
||||
{0.0, up[0], up[1], up[2]},
|
||||
{0.0, right[0], right[1], right[2]}}};
|
||||
}
|
||||
|
||||
int observer_static_schwarzschild_ks(double mass, double radius,
|
||||
ObserverState *out) {
|
||||
if (out == NULL || mass <= 0.0 || radius <= 2.0 * mass)
|
||||
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, 0.0, 0.0},
|
||||
/* e_(0) is the static four-velocity. e_(1) points inward; its time
|
||||
* component makes the tetrad orthonormal in the KS metric. */
|
||||
.tetrad = {{1.0 / normalization, 0.0, 0.0, 0.0},
|
||||
{-f / normalization, -normalization, 0.0, 0.0},
|
||||
{0.0, 0.0, 1.0, 0.0},
|
||||
{0.0, 0.0, 0.0, 1.0}}};
|
||||
return 0;
|
||||
}
|
||||
|
||||
int observer_inward_schwarzschild_ks(double mass, double radius,
|
||||
double inward_speed, ObserverState *out) {
|
||||
ObserverState static_observer;
|
||||
if (inward_speed < 0.0 || inward_speed >= 1.0 ||
|
||||
observer_static_schwarzschild_ks(mass, radius, &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);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#ifndef OBSERVER_H
|
||||
#define OBSERVER_H
|
||||
|
||||
typedef struct {
|
||||
double coordinate_time;
|
||||
double coordinate_position[3];
|
||||
/* e_(0), then spatial (forward, up, right), in coordinate components. */
|
||||
double tetrad[4][4];
|
||||
} ObserverState;
|
||||
|
||||
ObserverState observer_fixed_at_origin(void);
|
||||
/* Point the fixed inertial observer at an ICRS-style RA/Dec direction.
|
||||
* The local spatial axes remain (forward, celestial north, increasing RA). */
|
||||
ObserverState observer_fixed_at_origin_look_at(double ra_deg, double dec_deg);
|
||||
/* Static camera at (radius, 0, 0) in Cartesian Kerr--Schild coordinates,
|
||||
* directed toward the Schwarzschild black hole at the origin. */
|
||||
int observer_static_schwarzschild_ks(double mass, double radius,
|
||||
ObserverState *out);
|
||||
/* Camera at (radius, 0, 0), moving inward at local speed inward_speed with
|
||||
* respect to the static Schwarzschild observer. */
|
||||
int observer_inward_schwarzschild_ks(double mass, double radius,
|
||||
double inward_speed, ObserverState *out);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "optics.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef ENABLE_PNG
|
||||
#include <png.h>
|
||||
#endif
|
||||
|
||||
static double clamp(double value, double low, double high)
|
||||
{
|
||||
return value < low ? low : value > high ? high : value;
|
||||
}
|
||||
|
||||
/* Wyman, Sloan, and Shirley (2013), Eq. 4: analytic fits to the CIE 1931
|
||||
* 2-degree color-matching functions. Wavelength is in nanometres. */
|
||||
static void cie_1931_2deg(double wavelength_nm, double xyz[3])
|
||||
{
|
||||
const double x1 = (wavelength_nm - 442.0) *
|
||||
(wavelength_nm < 442.0 ? 0.0624 : 0.0374);
|
||||
const double x2 = (wavelength_nm - 599.8) *
|
||||
(wavelength_nm < 599.8 ? 0.0264 : 0.0323);
|
||||
const double x3 = (wavelength_nm - 501.1) *
|
||||
(wavelength_nm < 501.1 ? 0.0490 : 0.0382);
|
||||
const double y1 = (wavelength_nm - 568.8) *
|
||||
(wavelength_nm < 568.8 ? 0.0213 : 0.0247);
|
||||
const double y2 = (wavelength_nm - 530.9) *
|
||||
(wavelength_nm < 530.9 ? 0.0613 : 0.0322);
|
||||
const double z1 = (wavelength_nm - 437.0) *
|
||||
(wavelength_nm < 437.0 ? 0.0845 : 0.0278);
|
||||
const double z2 = (wavelength_nm - 459.0) *
|
||||
(wavelength_nm < 459.0 ? 0.0385 : 0.0725);
|
||||
xyz[0] = 0.362 * exp(-0.5 * x1 * x1) +
|
||||
1.056 * exp(-0.5 * x2 * x2) - 0.065 * exp(-0.5 * x3 * x3);
|
||||
xyz[1] = 0.821 * exp(-0.5 * y1 * y1) +
|
||||
0.286 * exp(-0.5 * y2 * y2);
|
||||
xyz[2] = 1.217 * exp(-0.5 * z1 * z1) +
|
||||
0.681 * exp(-0.5 * z2 * z2);
|
||||
}
|
||||
|
||||
static double planck_radiance_lambda(double wavelength_m, double temperature_K)
|
||||
{
|
||||
const double h = 6.62607015e-34;
|
||||
const double c = 299792458.0;
|
||||
const double k = 1.380649e-23;
|
||||
const double exponent = h * c / (wavelength_m * k * temperature_K);
|
||||
return 2.0 * h * c * c /
|
||||
(pow(wavelength_m, 5.0) * expm1(exponent));
|
||||
}
|
||||
|
||||
LinearRgb blackbody_to_linear_rgb(double temperature_K)
|
||||
{
|
||||
/* Integrate Planck spectral radiance from 380 to 780 nm into CIE XYZ,
|
||||
* then transform XYZ to linear sRGB. Results are W m^-2 sr^-1 in each
|
||||
* linear-primary channel, before the catalog amplitude and exposure. */
|
||||
double xyz[3] = {0.0, 0.0, 0.0};
|
||||
const double wavelength_step_m = 5e-9;
|
||||
if (!isfinite(temperature_K) || temperature_K <= 0.0)
|
||||
return (LinearRgb){0.0, 0.0, 0.0};
|
||||
for (int wavelength_nm = 380; wavelength_nm <= 780; wavelength_nm += 5) {
|
||||
double matching[3];
|
||||
const double radiance =
|
||||
planck_radiance_lambda(wavelength_nm * 1e-9, temperature_K);
|
||||
cie_1931_2deg(wavelength_nm, matching);
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
xyz[channel] += radiance * matching[channel] * wavelength_step_m;
|
||||
}
|
||||
return (LinearRgb){
|
||||
clamp(3.24096994 * xyz[0] - 1.53738318 * xyz[1] - 0.49861076 * xyz[2],
|
||||
0.0, INFINITY),
|
||||
clamp(-0.96924364 * xyz[0] + 1.87596750 * xyz[1] + 0.04155506 * xyz[2],
|
||||
0.0, INFINITY),
|
||||
clamp(0.05563008 * xyz[0] - 0.20397696 * xyz[1] + 1.05697151 * xyz[2],
|
||||
0.0, INFINITY)};
|
||||
}
|
||||
|
||||
void splat_moffat(double *hdr, int width, int height, double x, double y,
|
||||
LinearRgb color, double flux,
|
||||
const PointSpreadFunction *psf)
|
||||
{
|
||||
/* The tail omitted outside this radius contains 1e-8 of the Moffat's
|
||||
* total flux. Unlike the old fixed 3-sigma box, this is both circular and
|
||||
* far below the displayed HDR precision for the chosen beta. */
|
||||
const double tail_fraction = 1e-8;
|
||||
if (hdr == NULL || psf == NULL || flux <= 0.0 ||
|
||||
psf->fwhm_pixels <= 0.0 || psf->moffat_beta <= 1.0)
|
||||
return;
|
||||
const double beta = psf->moffat_beta;
|
||||
const double alpha = psf->fwhm_pixels /
|
||||
(2.0 * sqrt(pow(2.0, 1.0 / beta) - 1.0));
|
||||
const double support_radius = alpha * sqrt(
|
||||
pow(tail_fraction, 1.0 / (1.0 - beta)) - 1.0);
|
||||
const double support_radius_squared = support_radius * support_radius;
|
||||
const int min_x = (int)floor(x - support_radius);
|
||||
const int max_x = (int)ceil(x + support_radius);
|
||||
const int min_y = (int)floor(y - support_radius);
|
||||
const int max_y = (int)ceil(y + support_radius);
|
||||
const double normalization = flux * (beta - 1.0) /
|
||||
(3.14159265358979323846 * alpha * alpha);
|
||||
for (int py = min_y; py <= max_y; ++py) for (int px = min_x; px <= max_x; ++px) {
|
||||
if (px < 0 || px >= width || py < 0 || py >= height) continue;
|
||||
double dx = (px + 0.5) - x, dy = (py + 0.5) - y;
|
||||
const double radius_squared = dx * dx + dy * dy;
|
||||
if (radius_squared > support_radius_squared) continue;
|
||||
const double w = normalization *
|
||||
pow(1.0 + radius_squared / (alpha * alpha), -beta);
|
||||
double *pixel = &hdr[3 * (py * width + px)];
|
||||
pixel[0] += color.r * w; pixel[1] += color.g * w; pixel[2] += color.b * w;
|
||||
}
|
||||
}
|
||||
|
||||
static unsigned char tonemap_channel(double hdr_value)
|
||||
{
|
||||
/* Reinhard tone mapping followed by the sRGB display transfer curve. */
|
||||
const double linear = hdr_value / (1.0 + hdr_value);
|
||||
const double display = linear <= 0.0031308 ? 12.92 * linear
|
||||
: 1.055 * pow(linear, 1.0 / 2.4) - 0.055;
|
||||
return (unsigned char)lround(255.0 * clamp(display, 0.0, 1.0));
|
||||
}
|
||||
|
||||
static int write_tonemapped_ppm(const char *path, const double *hdr, 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 = tonemap_channel(hdr[i]);
|
||||
if (fwrite(&value, 1, 1, file) != 1) { fclose(file); return -1; }
|
||||
}
|
||||
return fclose(file) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
#ifdef ENABLE_PNG
|
||||
static int write_tonemapped_png(const char *path, const double *hdr, int width,
|
||||
int height)
|
||||
{
|
||||
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] = tonemap_channel(hdr[i]);
|
||||
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_end(png, info);
|
||||
result = 0;
|
||||
done:
|
||||
free(pixels);
|
||||
png_destroy_write_struct(&png, &info);
|
||||
if (fclose(file) != 0) result = -1;
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
int write_tonemapped_image(const char *path, const double *hdr, int width, int height)
|
||||
{
|
||||
const size_t path_length = strlen(path);
|
||||
if (path_length >= 4 && strcmp(path + path_length - 4, ".png") == 0) {
|
||||
#ifdef ENABLE_PNG
|
||||
return write_tonemapped_png(path, hdr, width, height);
|
||||
#else
|
||||
fputs("PNG output is disabled; rebuild with make ENABLE_PNG=1.\n", stderr);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
return write_tonemapped_ppm(path, hdr, width, height);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef OPTICS_H
|
||||
#define OPTICS_H
|
||||
|
||||
typedef struct { double r, g, b; } LinearRgb;
|
||||
typedef struct {
|
||||
double fwhm_pixels;
|
||||
double moffat_beta;
|
||||
} PointSpreadFunction;
|
||||
|
||||
/* Integrate a Planck spectrum into absolute linear-sRGB spectral radiance
|
||||
* (W m^-2 sr^-1), before catalog amplitude and display exposure. */
|
||||
LinearRgb blackbody_to_linear_rgb(double temperature_K);
|
||||
void splat_moffat(double *hdr, int width, int height, double x, double y,
|
||||
LinearRgb color, double flux,
|
||||
const PointSpreadFunction *psf);
|
||||
/* Writes PPM by default; a .png path requires a build with ENABLE_PNG=1. */
|
||||
int write_tonemapped_image(const char *path, const double *hdr, int width,
|
||||
int height);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,48 @@
|
||||
#ifndef SPACETIME_H
|
||||
#define SPACETIME_H
|
||||
|
||||
typedef struct {
|
||||
double alpha;
|
||||
double beta[3];
|
||||
double gamma[3][3];
|
||||
double K[3][3];
|
||||
double d_alpha[3];
|
||||
double d_beta[3][3]; /* d_beta[spatial derivative][component] */
|
||||
double d_gamma[3][3][3]; /* d_gamma[spatial derivative][j][k] */
|
||||
} MetricData;
|
||||
|
||||
typedef enum {
|
||||
SPACETIME_RAY_ACTIVE,
|
||||
SPACETIME_RAY_ESCAPED,
|
||||
SPACETIME_RAY_CAPTURED
|
||||
} SpacetimeRayStatus;
|
||||
|
||||
typedef struct SpacetimeSource SpacetimeSource;
|
||||
|
||||
typedef struct {
|
||||
int (*eval)(const SpacetimeSource *source, double t, const double x[3],
|
||||
MetricData *metric);
|
||||
SpacetimeRayStatus (*classify)(const SpacetimeSource *source, double t,
|
||||
const double x[3]);
|
||||
void (*destroy)(SpacetimeSource *source);
|
||||
} SpacetimeOps;
|
||||
|
||||
struct SpacetimeSource {
|
||||
const SpacetimeOps *ops;
|
||||
void *context;
|
||||
};
|
||||
|
||||
/* The selected build provides spacetime_create_default(). Named constructors
|
||||
* remain available to backend-specific tests and tools. */
|
||||
int spacetime_create_default(SpacetimeSource *source);
|
||||
int spacetime_create_minkowski(SpacetimeSource *source, double escape_radius);
|
||||
int spacetime_create_schwarzschild_ks(SpacetimeSource *source, double mass,
|
||||
double escape_radius,
|
||||
double capture_radius);
|
||||
void spacetime_destroy(SpacetimeSource *source);
|
||||
int spacetime_eval(const SpacetimeSource *source, double t, const double x[3],
|
||||
MetricData *metric);
|
||||
SpacetimeRayStatus spacetime_classify(const SpacetimeSource *source, double t,
|
||||
const double x[3]);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,22 @@
|
||||
#include "spacetime.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
void spacetime_destroy(SpacetimeSource *source) {
|
||||
if (source != NULL && source->ops != NULL)
|
||||
source->ops->destroy(source);
|
||||
}
|
||||
|
||||
int spacetime_eval(const SpacetimeSource *source, double t, const double x[3],
|
||||
MetricData *metric) {
|
||||
return source == NULL || source->ops == NULL
|
||||
? -1
|
||||
: source->ops->eval(source, t, x, metric);
|
||||
}
|
||||
|
||||
SpacetimeRayStatus spacetime_classify(const SpacetimeSource *source, double t,
|
||||
const double x[3]) {
|
||||
return source == NULL || source->ops == NULL
|
||||
? SPACETIME_RAY_CAPTURED
|
||||
: source->ops->classify(source, t, x);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "spacetime.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
typedef struct {
|
||||
double escape_radius;
|
||||
} MinkowskiContext;
|
||||
|
||||
static int minkowski_eval(const SpacetimeSource *source, double t,
|
||||
const double x[3], MetricData *metric) {
|
||||
(void)source;
|
||||
(void)t;
|
||||
(void)x;
|
||||
*metric = (MetricData){
|
||||
.alpha = 1.0,
|
||||
.gamma = {{1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0}}};
|
||||
return 0;
|
||||
}
|
||||
|
||||
static SpacetimeRayStatus minkowski_classify(const SpacetimeSource *source,
|
||||
double t, const double x[3]) {
|
||||
const MinkowskiContext *context = source->context;
|
||||
const double radius_squared = x[0] * x[0] + x[1] * x[1] + x[2] * x[2];
|
||||
(void)t;
|
||||
return radius_squared >= context->escape_radius * context->escape_radius
|
||||
? SPACETIME_RAY_ESCAPED
|
||||
: SPACETIME_RAY_ACTIVE;
|
||||
}
|
||||
|
||||
static void minkowski_destroy(SpacetimeSource *source) {
|
||||
free(source->context);
|
||||
source->context = NULL;
|
||||
source->ops = NULL;
|
||||
}
|
||||
|
||||
static const SpacetimeOps minkowski_ops = {
|
||||
.eval = minkowski_eval,
|
||||
.classify = minkowski_classify,
|
||||
.destroy = minkowski_destroy,
|
||||
};
|
||||
|
||||
int spacetime_create_minkowski(SpacetimeSource *source, double escape_radius) {
|
||||
if (source == NULL || escape_radius <= 0.0)
|
||||
return -1;
|
||||
MinkowskiContext *context = malloc(sizeof *context);
|
||||
if (context == NULL)
|
||||
return -1;
|
||||
context->escape_radius = escape_radius;
|
||||
source->ops = &minkowski_ops;
|
||||
source->context = context;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int spacetime_create_default(SpacetimeSource *source) {
|
||||
return spacetime_create_minkowski(source, 1024.0);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#include "spacetime.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
typedef struct {
|
||||
double mass;
|
||||
double escape_radius;
|
||||
double capture_radius;
|
||||
} SchwarzschildKsContext;
|
||||
|
||||
/* Schwarzschild in ingoing Cartesian Kerr--Schild coordinates:
|
||||
* g_mu_nu = eta_mu_nu + (2 M / r) l_mu l_nu, l_mu = (1, x_i / r).
|
||||
* These slices are regular at r = 2 M; only the physical r = 0 singularity
|
||||
* is excluded by the conservative capture cutoff. */
|
||||
static int schwarzschild_ks_eval(const SpacetimeSource *source, double t,
|
||||
const double x[3], MetricData *metric) {
|
||||
const SchwarzschildKsContext *context = source->context;
|
||||
double r2 = 0.0;
|
||||
(void)t;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
r2 += x[i] * x[i];
|
||||
if (!isfinite(r2) || r2 <= 0.0)
|
||||
return -1;
|
||||
const double r = sqrt(r2);
|
||||
const double m = context->mass;
|
||||
const double f = 2.0 * m / r;
|
||||
const double alpha = 1.0 / sqrt(1.0 + f);
|
||||
const double beta_scale = 2.0 * m / (r * (r + 2.0 * m));
|
||||
const double dbeta_scale_dr =
|
||||
-4.0 * m * (r + m) / (r2 * (r + 2.0 * m) * (r + 2.0 * m));
|
||||
*metric = (MetricData){.alpha = alpha};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
metric->beta[i] = beta_scale * x[i];
|
||||
metric->d_alpha[i] = m * alpha * alpha * alpha * x[i] / (r2 * r);
|
||||
for (int j = 0; j < 3; ++j) {
|
||||
metric->gamma[i][j] = (i == j ? 1.0 : 0.0) +
|
||||
2.0 * m * x[i] * x[j] / (r2 * r);
|
||||
metric->d_beta[i][j] = beta_scale * (i == j ? 1.0 : 0.0) +
|
||||
dbeta_scale_dr * x[i] * x[j] / r;
|
||||
for (int k = 0; k < 3; ++k)
|
||||
metric->d_gamma[i][j][k] =
|
||||
2.0 * m * (((i == j ? 1.0 : 0.0) * x[k] +
|
||||
(i == k ? 1.0 : 0.0) * x[j]) /
|
||||
(r2 * r) -
|
||||
3.0 * x[i] * x[j] * x[k] / (r2 * r2 * r));
|
||||
}
|
||||
}
|
||||
/* K_ij = (D_i beta_j + D_j beta_i)/(2 alpha) for these stationary slices.
|
||||
* Build the connection from the analytic spatial-metric derivatives above;
|
||||
* this avoids finite differences in the geodesic RHS. */
|
||||
for (int i = 0; i < 3; ++i)
|
||||
for (int j = 0; j < 3; ++j) {
|
||||
double d_beta_cov_i_j =
|
||||
2.0 * m * ((i == j ? 1.0 : 0.0) / r2 -
|
||||
2.0 * x[i] * x[j] / (r2 * r2));
|
||||
double d_beta_cov_j_i = d_beta_cov_i_j;
|
||||
double connection_term_ij = 0.0;
|
||||
double connection_term_ji = 0.0;
|
||||
for (int ell = 0; ell < 3; ++ell) {
|
||||
double gamma_inverse_ell_k[3];
|
||||
for (int k = 0; k < 3; ++k)
|
||||
gamma_inverse_ell_k[k] = (ell == k ? 1.0 : 0.0) -
|
||||
f / (1.0 + f) * x[ell] * x[k] / r2;
|
||||
double gamma_ell_ij = 0.0, gamma_ell_ji = 0.0;
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
gamma_ell_ij += 0.5 * gamma_inverse_ell_k[k] *
|
||||
(metric->d_gamma[i][k][j] +
|
||||
metric->d_gamma[j][k][i] -
|
||||
metric->d_gamma[k][i][j]);
|
||||
gamma_ell_ji += 0.5 * gamma_inverse_ell_k[k] *
|
||||
(metric->d_gamma[j][k][i] +
|
||||
metric->d_gamma[i][k][j] -
|
||||
metric->d_gamma[k][j][i]);
|
||||
}
|
||||
const double beta_cov_ell = 2.0 * m * x[ell] / r2;
|
||||
connection_term_ij += gamma_ell_ij * beta_cov_ell;
|
||||
connection_term_ji += gamma_ell_ji * beta_cov_ell;
|
||||
}
|
||||
metric->K[i][j] = (d_beta_cov_i_j - connection_term_ij +
|
||||
d_beta_cov_j_i - connection_term_ji) /
|
||||
(2.0 * alpha);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static SpacetimeRayStatus schwarzschild_ks_classify(
|
||||
const SpacetimeSource *source, double t, const double x[3]) {
|
||||
const SchwarzschildKsContext *context = source->context;
|
||||
double r2 = 0.0;
|
||||
(void)t;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
r2 += x[i] * x[i];
|
||||
if (!isfinite(r2) || r2 <= context->capture_radius * context->capture_radius)
|
||||
return SPACETIME_RAY_CAPTURED;
|
||||
return r2 >= context->escape_radius * context->escape_radius
|
||||
? SPACETIME_RAY_ESCAPED
|
||||
: SPACETIME_RAY_ACTIVE;
|
||||
}
|
||||
|
||||
static void schwarzschild_ks_destroy(SpacetimeSource *source) {
|
||||
free(source->context);
|
||||
source->context = NULL;
|
||||
source->ops = NULL;
|
||||
}
|
||||
|
||||
static const SpacetimeOps schwarzschild_ks_ops = {
|
||||
.eval = schwarzschild_ks_eval,
|
||||
.classify = schwarzschild_ks_classify,
|
||||
.destroy = schwarzschild_ks_destroy,
|
||||
};
|
||||
|
||||
int spacetime_create_schwarzschild_ks(SpacetimeSource *source, double mass,
|
||||
double escape_radius,
|
||||
double capture_radius) {
|
||||
if (source == NULL || mass <= 0.0 || escape_radius <= 2.0 * mass ||
|
||||
capture_radius <= 0.0 || capture_radius >= 2.0 * mass ||
|
||||
capture_radius >= escape_radius)
|
||||
return -1;
|
||||
SchwarzschildKsContext *context = malloc(sizeof *context);
|
||||
if (context == NULL)
|
||||
return -1;
|
||||
*context = (SchwarzschildKsContext){mass, escape_radius, capture_radius};
|
||||
source->ops = &schwarzschild_ks_ops;
|
||||
source->context = context;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int spacetime_create_default(SpacetimeSource *source) {
|
||||
return spacetime_create_schwarzschild_ks(source, 1.0, 256.0, 1.5);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "frame.h"
|
||||
#include "optics.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <omp.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(void) {
|
||||
const int width = 100, height = 100;
|
||||
const PointSpreadFunction psf = {.fwhm_pixels = 2.7, .moffat_beta = 4.5};
|
||||
const GeodesicTraceConfig trace = {.coordinate_time_step = 0.25,
|
||||
.max_steps = 100};
|
||||
const ObserverState observer = observer_fixed_at_origin();
|
||||
Star star = {
|
||||
.direction = {0.0, 0.0, -1.0}, .temperature_K = 7000.0, .amplitude = 1.0};
|
||||
StarCatalog catalog = {.stars = &star, .count = 1};
|
||||
SpacetimeSource spacetime = {0};
|
||||
FrameLensMesh mesh = {0};
|
||||
double *hdr = calloc((size_t)width * height * 3, sizeof *hdr);
|
||||
int result = 1;
|
||||
if (hdr == NULL || spacetime_create_minkowski(&spacetime, 10.0) ||
|
||||
frame_lens_mesh_build_coarse(&mesh, width, height, 20, 30.0) ||
|
||||
frame_lens_mesh_trace(&mesh, &spacetime, &observer, &trace))
|
||||
goto done;
|
||||
const size_t images =
|
||||
frame_splat_catalog(&mesh, &catalog, hdr, width, height, 100.0, &psf);
|
||||
if (images != 1 || hdr[3 * (50 * width + 50)] <= 0.0) {
|
||||
fputs("flat-space inverse lens-map regression failed\n", stderr);
|
||||
goto done;
|
||||
}
|
||||
/* Private HDR accumulation must preserve the serial splat result. */
|
||||
double *serial_hdr = calloc((size_t)width * height * 3, sizeof *serial_hdr);
|
||||
double *parallel_hdr = calloc((size_t)width * height * 3, sizeof *parallel_hdr);
|
||||
if (serial_hdr == NULL || parallel_hdr == NULL) {
|
||||
free(serial_hdr);
|
||||
free(parallel_hdr);
|
||||
goto done;
|
||||
}
|
||||
const int original_threads = omp_get_max_threads();
|
||||
omp_set_dynamic(0);
|
||||
omp_set_num_threads(1);
|
||||
const size_t serial_images = frame_splat_catalog(
|
||||
&mesh, &catalog, serial_hdr, width, height, 100.0, &psf);
|
||||
omp_set_num_threads(4);
|
||||
const size_t parallel_images = frame_splat_catalog(
|
||||
&mesh, &catalog, parallel_hdr, width, height, 100.0, &psf);
|
||||
omp_set_num_threads(original_threads);
|
||||
for (int value = 0; value < width * height * 3; ++value)
|
||||
if (fabs(serial_hdr[value] - parallel_hdr[value]) >
|
||||
1e-12 * fmax(1.0, fabs(serial_hdr[value]))) {
|
||||
fputs("parallel catalog splat regression failed\n", stderr);
|
||||
free(serial_hdr);
|
||||
free(parallel_hdr);
|
||||
goto done;
|
||||
}
|
||||
free(serial_hdr);
|
||||
free(parallel_hdr);
|
||||
if (serial_images != 1 || parallel_images != serial_images) {
|
||||
fputs("parallel catalog image-count regression failed\n", stderr);
|
||||
goto done;
|
||||
}
|
||||
const LinearRgb cool = blackbody_to_linear_rgb(3000.0);
|
||||
const LinearRgb hot = blackbody_to_linear_rgb(10000.0);
|
||||
if (!(cool.r > cool.b && hot.b > hot.r &&
|
||||
hot.r + hot.g + hot.b > cool.r + cool.g + cool.b)) {
|
||||
fputs("blackbody spectral-color regression failed\n", stderr);
|
||||
goto done;
|
||||
}
|
||||
/* The Moffat is flux-normalized and retains a measurable, continuous wing
|
||||
* beyond the former Gaussian's 3-sigma raster box. */
|
||||
memset(hdr, 0, (size_t)width * height * 3 * sizeof *hdr);
|
||||
splat_moffat(hdr, width, height, 50.5, 50.5,
|
||||
(LinearRgb){1.0, 1.0, 1.0}, 1.0, &psf);
|
||||
double moffat_flux = 0.0;
|
||||
for (int pixel = 0; pixel < width * height; ++pixel)
|
||||
moffat_flux += hdr[3 * pixel];
|
||||
if (fabs(moffat_flux - 1.0) > 0.01 ||
|
||||
hdr[3 * (50 * width + 62)] <= 0.0) {
|
||||
fputs("Moffat normalization or wing regression failed\n", stderr);
|
||||
goto done;
|
||||
}
|
||||
frame_draw_mesh(&mesh, hdr, width, height, 0.5, 0.5);
|
||||
if (hdr[3 * (10 * width + 20)] != 0.25) {
|
||||
fputs("mesh diagnostic overlay regression failed\n", stderr);
|
||||
goto done;
|
||||
}
|
||||
/* A fixed absolute edge tolerance used to make tiny source triangles claim
|
||||
* sources far outside their field. */
|
||||
FrameLensMesh fine_mesh = {0};
|
||||
Star fine_stars[2] = {{.direction = {0.0, 0.0, -1.0},
|
||||
.temperature_K = 7000.0,
|
||||
.amplitude = 1.0},
|
||||
{.direction = {0.01, 0.0, -0.9999499987499375},
|
||||
.temperature_K = 7000.0,
|
||||
.amplitude = 1.0}};
|
||||
StarCatalog fine_catalog = {.stars = fine_stars, .count = 2};
|
||||
memset(hdr, 0, (size_t)width * height * 3 * sizeof *hdr);
|
||||
if (frame_lens_mesh_build_coarse(&fine_mesh, width, height, 1, 0.1) ||
|
||||
frame_lens_mesh_trace(&fine_mesh, &spacetime, &observer, &trace) ||
|
||||
frame_splat_catalog(&fine_mesh, &fine_catalog, hdr, width, height,
|
||||
100.0, &psf) != 1) {
|
||||
fputs("fine source-triangle containment regression failed\n", stderr);
|
||||
frame_lens_mesh_destroy(&fine_mesh);
|
||||
goto done;
|
||||
}
|
||||
frame_lens_mesh_destroy(&fine_mesh);
|
||||
result = 0;
|
||||
done:
|
||||
frame_lens_mesh_destroy(&mesh);
|
||||
spacetime_destroy(&spacetime);
|
||||
free(hdr);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "geodesic.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static int nearly_equal(double a, double b) { return fabs(a - b) < 1e-12; }
|
||||
|
||||
static int check_ray(const SpacetimeSource *source,
|
||||
const ObserverState *observer,
|
||||
const double local_direction[3],
|
||||
const double expected[3]) {
|
||||
const GeodesicTraceConfig config = {.coordinate_time_step = 0.25,
|
||||
.max_steps = 100};
|
||||
RayEndpoint ray =
|
||||
geodesic_trace_past(source, observer, local_direction, &config);
|
||||
if (ray.status != RAY_ENDPOINT_ESCAPED ||
|
||||
!nearly_equal(ray.frequency_ratio, 1.0) ||
|
||||
!nearly_equal(ray.n_infinity[0], expected[0]) ||
|
||||
!nearly_equal(ray.n_infinity[1], expected[1]) ||
|
||||
!nearly_equal(ray.n_infinity[2], expected[2])) {
|
||||
fprintf(stderr, "flat-space geodesic regression failed\n");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
SpacetimeSource source = {0};
|
||||
const ObserverState observer = observer_fixed_at_origin();
|
||||
if (spacetime_create_minkowski(&source, 10.0))
|
||||
return 1;
|
||||
int result = check_ray(&source, &observer, (double[]){1.0, 0.0, 0.0},
|
||||
(double[]){0.0, 0.0, -1.0}) ||
|
||||
check_ray(&source, &observer, (double[]){0.0, 0.0, 1.0},
|
||||
(double[]){1.0, 0.0, 0.0});
|
||||
const ObserverState look_at_ra_zero =
|
||||
observer_fixed_at_origin_look_at(0.0, 0.0);
|
||||
result = result || check_ray(&source, &look_at_ra_zero,
|
||||
(double[]){1.0, 0.0, 0.0},
|
||||
(double[]){1.0, 0.0, 0.0});
|
||||
spacetime_destroy(&source);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "geodesic.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
SpacetimeSource spacetime = {0};
|
||||
MetricData metric;
|
||||
ObserverState observer;
|
||||
ObserverState inward_observer;
|
||||
const GeodesicTraceConfig trace = {.coordinate_time_step = 0.1,
|
||||
.max_steps = 4096,
|
||||
.capture_log_alpha_p0 = 8.0};
|
||||
int result = 1;
|
||||
if (spacetime_create_schwarzschild_ks(&spacetime, 1.0, 256.0, 1.5) ||
|
||||
spacetime_eval(&spacetime, 0.0, (double[]){2.0, 0.0, 0.0}, &metric) ||
|
||||
!isfinite(metric.alpha) || !isfinite(metric.gamma[0][0]) ||
|
||||
!isfinite(metric.K[0][0]) ||
|
||||
observer_static_schwarzschild_ks(1.0, 30.0, &observer) ||
|
||||
observer_inward_schwarzschild_ks(1.0, 30.0, 0.5,
|
||||
&inward_observer) ||
|
||||
fabs(inward_observer.tetrad[0][0] -
|
||||
(2.0 / sqrt(3.0)) * (observer.tetrad[0][0] +
|
||||
0.5 * observer.tetrad[1][0])) >
|
||||
1e-12 ||
|
||||
fabs(inward_observer.tetrad[1][1] -
|
||||
(2.0 / sqrt(3.0)) * (0.5 * observer.tetrad[0][1] +
|
||||
observer.tetrad[1][1])) >
|
||||
1e-12 ||
|
||||
!observer_inward_schwarzschild_ks(1.0, 30.0, 1.0,
|
||||
&inward_observer))
|
||||
goto done;
|
||||
const RayEndpoint central = geodesic_trace_past(
|
||||
&spacetime, &observer, (double[]){1.0, 0.0, 0.0}, &trace);
|
||||
const RayEndpoint inside_shadow = geodesic_trace_past(
|
||||
&spacetime, &observer, (double[]){cos(0.10), sin(0.10), 0.0}, &trace);
|
||||
const RayEndpoint outside_shadow = geodesic_trace_past(
|
||||
&spacetime, &observer, (double[]){cos(0.30), sin(0.30), 0.0}, &trace);
|
||||
if (central.status != RAY_ENDPOINT_CAPTURED ||
|
||||
inside_shadow.status != RAY_ENDPOINT_CAPTURED ||
|
||||
outside_shadow.status != RAY_ENDPOINT_ESCAPED) {
|
||||
fprintf(stderr,
|
||||
"Schwarzschild KS shadow regression failed (center=%d, inside=%d, "
|
||||
"outside=%d)\n",
|
||||
central.status, inside_shadow.status, outside_shadow.status);
|
||||
goto done;
|
||||
}
|
||||
result = 0;
|
||||
done:
|
||||
spacetime_destroy(&spacetime);
|
||||
return result;
|
||||
}
|
||||