From 4c575fa8b55d2c0b36905d4c0a4980935cddf3df Mon Sep 17 00:00:00 2001 From: Yingjie Wang Date: Sat, 29 Aug 2026 00:44:20 -0400 Subject: [PATCH] Frame: refine critical lens regions --- README.md | 25 ++++++++++++++++++++----- src/frame.c | 35 +++++++++++++++++++++++++++++++++++ src/frame.h | 10 +++++++--- src/main.c | 4 ++++ tests/test_frame.c | 25 ++++++++++++++++++++++++- tests/test_schwarzschild.c | 1 + video_rendering_plan.md | 2 +- 7 files changed, 92 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 461be75..a7564b1 100644 --- a/README.md +++ b/README.md @@ -147,13 +147,15 @@ production refinement threshold. Adaptive refinement is disabled by default (`--refine-max-level 0`), so the existing coarse-mesh renders remain unchanged. When enabled, its defaults are an absolute direction error of `1e-3` degrees, relative error `0.1`, minimum -long edge `0.5` pixels, and minimum area `0.25` pixel-squared. Each value can -be overridden independently: +long edge `0.5` pixels, minimum area `0.25` pixel-squared, and a provisional +minimum discrete-Jacobian magnitude of `1e-3`. Each value can be overridden +independently: ```text --refine-max-level N --refine-angle-abs-deg D --refine-angle-rel R +--refine-jacobian-min J --refine-min-edge-pixels P --refine-min-area-pixels2 A ``` @@ -171,8 +173,20 @@ prevent selecting a leaf already at or below the requested image-plane long-edge and area scales. Triangles whose three vertices disagree between capture and escape are split independently of the direction-error thresholds, allowing the mesh to follow a -shadow boundary. This first implementation deliberately does not evaluate -orientation or Jacobian criteria. +shadow boundary. + +Independently of the midpoint geometry test, an all-escaped triangle also +computes the discrete lens Jacobian +`J = Omega_source / Omega_image`. Both signed solid angles use +`2 atan2(dot(a, cross(b,c)), 1 + dot(a,b) + dot(b,c) + dot(c,a))`, with the +ordered camera directions for `Omega_image` and their traced infinity +directions for `Omega_source`. `abs(J) < --refine-jacobian-min` requests a +split near a critical curve. The signed-area calculation retains the sign of +`J`, but sign changes across adjacent triangles are not currently a refinement +trigger; this makes it possible to evaluate the `abs(J)` criterion separately. +The `1e-3` default is deliberately provisional and should be tuned with the +small Schwarzschild refinement diagnostic before being treated as a production +threshold. For a short Schwarzschild diagnostic that permits at most one actual split generation, for example: @@ -181,7 +195,8 @@ generation, for example: ./build/schwarzschild_sky --catalog assets/sky_grid_5deg.csv \ --width 48 --height 48 --coarse-cell-pixels 24 --fov-deg 40 \ --refine-max-level 1 --refine-angle-abs-deg 0.001 \ - --refine-angle-rel 0.001 --refine-min-edge-pixels 1 \ + --refine-angle-rel 0.001 --refine-jacobian-min 0.001 \ + --refine-min-edge-pixels 1 \ --refine-min-area-pixels2 1 --draw-mesh \ --output output/imgs/schwarzschild_refinement.png ``` diff --git a/src/frame.c b/src/frame.c index 26ed786..904ea4e 100644 --- a/src/frame.c +++ b/src/frame.c @@ -335,6 +335,37 @@ static double direction_angle(const double a[3], const double b[3]) { return acos(product); } +static double spherical_signed_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(dot(a, b_cross_c), + 1.0 + dot(a, b) + dot(b, c) + dot(c, a)); +} + +/* Returns whether the discrete source/image solid-angle ratio is available. + * Captured and degenerate image triangles have no reliable value. */ +static int discrete_jacobian(const FrameLensMesh *mesh, + const LensTriangle *triangle, double *value) { + const LensVertex *a = &mesh->vertices[triangle->vertex[0]]; + const LensVertex *b = &mesh->vertices[triangle->vertex[1]]; + const LensVertex *c = &mesh->vertices[triangle->vertex[2]]; + if (a->status != RAY_ENDPOINT_ESCAPED || b->status != RAY_ENDPOINT_ESCAPED || + c->status != RAY_ENDPOINT_ESCAPED) + return 0; + const double image_area = spherical_signed_area( + a->camera_direction, b->camera_direction, c->camera_direction); + if (!isfinite(image_area) || fabs(image_area) <= 1e-15) + return 0; + const double source_area = spherical_signed_area( + a->n_infinity, b->n_infinity, c->n_infinity); + const double jacobian = source_area / image_area; + if (!isfinite(jacobian)) + return 0; + *value = jacobian; + return 1; +} + static int probe_requires_split(const FrameLensMesh *mesh, const LensTriangle *triangle, unsigned int side, @@ -441,6 +472,10 @@ int frame_lens_mesh_finish_generation(FrameLensMesh *mesh, if (allowed[i]) { const unsigned int side = longest_side(mesh, triangle); requested[3 * i + side] = probe_requires_split(mesh, triangle, side, config); + double jacobian; + if (discrete_jacobian(mesh, triangle, &jacobian) && + fabs(jacobian) < config->jacobian_minimum) + requested[3 * i + side] = 1; } } qsort(edges, edge_count, sizeof *edges, compare_mesh_edge); diff --git a/src/frame.h b/src/frame.h index 6c3d499..175666a 100644 --- a/src/frame.h +++ b/src/frame.h @@ -28,11 +28,15 @@ typedef struct { unsigned int max_level; double angle_absolute_rad; double angle_relative; + double jacobian_minimum; double min_edge_pixels; double min_area_pixels2; } RefinementConfig; -typedef enum { FRAME_SAMPLE_VERTEX, FRAME_SAMPLE_PROBE } FrameSampleKind; +typedef enum { + FRAME_SAMPLE_VERTEX, + FRAME_SAMPLE_PROBE +} FrameSampleKind; typedef struct { FrameSampleKind kind; @@ -74,8 +78,8 @@ int frame_lens_mesh_build_coarse(FrameLensMesh *mesh, int width, int height, int frame_lens_mesh_trace(FrameLensMesh *mesh, const SpacetimeSource *spacetime, const ObserverState *observer, const GeodesicTraceConfig *trace); -/* Builds exactly one generation of requests. Probe results must be installed - * only after the caller has completed the generation's tracing sweep. */ +/* Builds one generation of vertex/probe ray requests. Results are installed + * only after the caller has completed the generation's sweep. */ int frame_lens_mesh_prepare_generation(FrameLensMesh *mesh, const RefinementConfig *config); const FrameSample *frame_lens_mesh_samples(const FrameLensMesh *mesh, diff --git a/src/main.c b/src/main.c index a42d348..8261826 100644 --- a/src/main.c +++ b/src/main.c @@ -154,6 +154,7 @@ static int parse_args(int argc, char **argv, Settings *s, .refinement = {.angle_absolute_rad = 1e-3 * 3.14159265358979323846 / 180.0, .angle_relative = 0.1, + .jacobian_minimum = 1e-3, .min_edge_pixels = 0.5, .min_area_pixels2 = 0.25}}; *write_path = NULL; @@ -181,6 +182,8 @@ static int parse_args(int argc, char **argv, Settings *s, s->refinement.angle_absolute_rad *= 3.14159265358979323846 / 180.0; } else if (!strcmp(argv[i], "--refine-angle-rel") && i + 1 < argc && !parse_positive(argv[++i], &s->refinement.angle_relative)) { + } else if (!strcmp(argv[i], "--refine-jacobian-min") && i + 1 < argc && + !parse_positive(argv[++i], &s->refinement.jacobian_minimum)) { } else if (!strcmp(argv[i], "--refine-min-edge-pixels") && i + 1 < argc && !parse_positive(argv[++i], &s->refinement.min_edge_pixels)) { } else if (!strcmp(argv[i], "--refine-min-area-pixels2") && i + 1 < argc && @@ -528,6 +531,7 @@ int main(int argc, char **argv) { #endif "[--coarse-cell-pixels N] [--refine-max-level N " "--refine-angle-abs-deg D --refine-angle-rel R " + "--refine-jacobian-min J " "--refine-min-edge-pixels P --refine-min-area-pixels2 A] " "[--draw-mesh] [--write-catalog PATH] " "[--catalog-load-workers N] " diff --git a/tests/test_frame.c b/tests/test_frame.c index be8d520..46e986a 100644 --- a/tests/test_frame.c +++ b/tests/test_frame.c @@ -164,9 +164,10 @@ int main(void) { * shared diagonal probe must produce one stable midpoint and conforming * children only after its endpoint has been installed. */ FrameLensMesh adaptive_mesh = {0}; - const RefinementConfig refine = {.max_level = 1, + RefinementConfig refine = {.max_level = 1, .angle_absolute_rad = 1e-4, .angle_relative = 1e-4, + .jacobian_minimum = 1e-3, .min_edge_pixels = 1.0, .min_area_pixels2 = 1.0}; if (frame_lens_mesh_build_coarse(&adaptive_mesh, width, height, 100, 30.0)) @@ -192,6 +193,28 @@ int main(void) { goto done; } frame_lens_mesh_destroy(&adaptive_mesh); + /* A zero discrete Jacobian is an independent fold/critical-band trigger: + * the probe below agrees exactly with linear endpoint interpolation, so the + * direction-error criterion cannot request this split. */ + if (frame_lens_mesh_build_coarse(&adaptive_mesh, width, height, 100, 30.0)) + goto done; + for (size_t i = 0; i < adaptive_mesh.vertex_count; ++i) { + adaptive_mesh.vertices[i].traced = 1; + adaptive_mesh.vertices[i].status = RAY_ENDPOINT_ESCAPED; + adaptive_mesh.vertices[i].n_infinity[0] = 1.0; + } + const RayEndpoint flat_probe = {.n_infinity = {1.0, 0.0, 0.0}, + .frequency_ratio = 1.0, + .status = RAY_ENDPOINT_ESCAPED}; + if (frame_lens_mesh_prepare_generation(&adaptive_mesh, &refine) != 1 || + frame_lens_mesh_install_sample(&adaptive_mesh, 0, &flat_probe) || + frame_lens_mesh_finish_generation(&adaptive_mesh, &refine) != 1 || + adaptive_mesh.vertex_count != 5 || adaptive_mesh.triangle_count != 4) { + fputs("adaptive discrete-Jacobian split regression failed\n", stderr); + frame_lens_mesh_destroy(&adaptive_mesh); + goto done; + } + frame_lens_mesh_destroy(&adaptive_mesh); result = 0; done: frame_lens_mesh_destroy(&mesh); diff --git a/tests/test_schwarzschild.c b/tests/test_schwarzschild.c index 784db15..c0cb151 100644 --- a/tests/test_schwarzschild.c +++ b/tests/test_schwarzschild.c @@ -64,6 +64,7 @@ int main(void) { const RefinementConfig refinement = {.max_level = 1, .angle_absolute_rad = 1e-5, .angle_relative = 1e-5, + .jacobian_minimum = 1e-3, .min_edge_pixels = 1.0, .min_area_pixels2 = 1.0}; if (frame_lens_mesh_build_coarse(&mesh, 48, 48, 24, 40.0) || diff --git a/video_rendering_plan.md b/video_rendering_plan.md index 8bb17ce..e02ed46 100644 --- a/video_rendering_plan.md +++ b/video_rendering_plan.md @@ -88,7 +88,7 @@ SampleRequest -> RayPool (SoA, inactive / active / terminated) ## Phase D:补全多 pass adaptive mesh 与 out-of-core 准备(未开始) -1. `FrameLensMesh` 采用 append-only stable vertex ID、generation-local request/probe 队列、triangle level 与“缺失 endpoint”状态。每 pass 的 probe 只放在最长 image-plane 边中点;完整 sweep 后才安装 endpoint 并改变拓扑。细分要求同时超过绝对与相对 `n_infinity` 方向误差阈值,并受最大层数、最小长边和最小面积约束;capture/escape 不一致强制细分。首版不计算 orientation 或 Jacobian。 +1. `FrameLensMesh` 采用 append-only stable vertex ID、generation-local request/probe 队列、triangle level 与“缺失 endpoint”状态。每 pass 的 probe 只放在最长 image-plane 边中点;完整 sweep 后才安装 endpoint 并改变拓扑。细分保留同时超过绝对与相对 `n_infinity` 方向误差阈值的几何判据,并受最大层数、最小长边和最小面积约束;capture/escape 不一致强制细分。另以三顶点的有向球面面积比 `J = Omega_source / Omega_image` 独立检测临界带:当前仅 `abs(J)` 小于阈值时细分;相邻三角形的 `J` 异号细分暂不启用,留待验证 `abs(J)` 的覆盖能力后再决定。 2. 以迭代 queue(可按 frame/root tile 并行、线程本地 request buffer 后 sort/deduplicate)替代递归 task。每一个 pass 完整执行 Phase C sweep;只有所有 frame 都无新请求才进行 catalog splat。 3. 在 movie 生命周期中及时释放已完成的 RayPool、临时 request 和单帧 HDR;保留最终 mesh/endpoints,或在渲染 PNG 后按明确策略释放,避免视频时无界增长。 4. 为 nmesh 预留并实现 source-side slab overlap / temporal ghost-slice 契约、可配置 memory budget、slab coverage 日志和线程本地 `MetricWorkspace` ownership。此 phase 不重采样为 Cartesian grid,也不假定相邻时间 slice 的 AMR tree 相同。