Skip to content

DisplacedSurface: hits and shadow-ray origins are offset by ~1.5e-3 scene units (scene_epsilon), visibly truncating shadows of shallow terrain — please decouple the offset from the marching step #71

Description

@albireo77

Environment

  • PlotOptiX 0.19.0, Python 3.9, Windows 10
  • NVIDIA GeForce RTX 3060 Ti

Summary

Geometry rendered with GeomAttributeProgram.DisplacedSurface reports hit positions (and, more importantly, starts secondary/shadow rays) about 1.5e-3 scene units above the actual displaced surface. For terrain whose relief is small compared to the object size, this truncates every shadow tip by approximately

delta_L = epsilon / tan(sun_altitude)

which becomes very visible at grazing illumination.

I found that the offset is controlled by the (apparently undocumented) scene_epsilon variable, settable via rt.set_float("scene_epsilon", value). That works — but the ray-marching step appears to be tied to the same value, so the render cost grows roughly proportionally to 1/epsilon. Accuracy and performance are currently the same knob, although only the offset needs to be small; the step could stay coarse.

Minimal reproduction

A displaced sphere with a uniform displacement map (exact surface radius 9.95), hit positions read back with get_hit_at:

import threading, time
from ctypes import c_float, byref
import numpy as np
from plotoptix import NpOptiX
from plotoptix.materials import m_diffuse

done = threading.Event()
def on_done(rt): done.set()

R, FLAT = 10.0, 0.995                       # exact surface radius = 9.95
disp = np.full((1024, 2048), FLAT, dtype=np.float32)

rt = NpOptiX(on_rt_accum_done=on_done, width=600, height=600, start_now=False)
rt.set_param(min_accumulation_step=4, max_accumulation_frames=8)
rt.set_background(0); rt.set_ambient(0.8)
rt.update_material("diffuse", m_diffuse)
rt.set_data("ball", geom="ParticleSetTextured", geom_attr="DisplacedSurface",
            pos=[0, 0, 0], u=[0, 0, 1], v=[0, -1, 0], r=R, c=0.9)
rt.set_displacement("ball", disp, refresh=False)
rt.setup_camera("cam", eye=[0, -40, 0], target=[0, 0, 0], up=[0, 0, 1], fov=10.0)
rt.start()

def mean_hit_radius():
    cx = c_float(); cy = c_float(); cz = c_float(); cd = c_float()
    radii = []
    for py in range(200, 400, 10):
        for px in range(200, 400, 10):
            if rt._optix.get_hit_at(px, py, byref(cx), byref(cy), byref(cz), byref(cd)) and cd.value > 0:
                radii.append(np.linalg.norm([cx.value, cy.value, cz.value]))
    return float(np.mean(radii))

done.wait(120)
t0 = time.time(); done.clear(); rt.refresh_scene(); done.wait(120)
t_default = time.time() - t0
r_default = mean_hit_radius()

rt.set_float("scene_epsilon", 1.5e-4)
done.clear(); rt.refresh_scene(); done.wait(120)
t0 = time.time(); done.clear(); rt.refresh_scene(); done.wait(120)
t_small = time.time() - t0
r_small = mean_hit_radius()

print(f"exact surface radius:              {R*FLAT:.5f}")
print(f"hit radius, default epsilon:       {r_default:.5f}  (lift {r_default-R*FLAT:+.5f}), {t_default:.2f} s/8 frames")
print(f"hit radius, scene_epsilon=1.5e-4:  {r_small:.5f}  (lift {r_small-R*FLAT:+.5f}), {t_small:.2f} s/8 frames")

Output on my machine:

exact surface radius:              9.95000
hit radius, default epsilon:       9.95126  (lift +0.00126), 0.06 s/8 frames
hit radius, scene_epsilon=1.5e-4:  9.95011  (lift +0.00011), 0.42 s/8 frames

Real-world impact

I render the Moon from the LRO/LOLA elevation model (sphere radius 10, real relief scale, so a 700 m lunar hill is only 4e-4 of the radius). At grazing sun angles near the terminator, shadows render 5–7 km too short — equivalent to about 2 hours of shadow evolution, clearly wrong next to real photographs of the same instant. Measured against a ground-truth shadow ray-march over the same DEM (mountain shadow at 2.8° sun altitude, geometric length 18.9 km):

scene_epsilon render time (32 frames) hit lift rendered shadow
default (~1.5e-3) 1.0x +261 m 12.3 km
7.5e-4 2.4x +94 m 15.0 km
3e-4 4.2x +38 m 15.9 km
1.5e-4 9.2x +20 m 16.8 km

With scene_epsilon reduced ~10x the shadows match reality (verified against photographs), but rendering is ~9x slower, which hurts interactive use.

Request

  1. Document scene_epsilon (it already works via set_float, which is great).
  2. Decouple the self-intersection offset from the marching step. Only the hit/shadow-ray offset needs to be small for correct shadows; the traversal step could remain coarse. Two epsilons (or a refinement pass that brackets the crossing with the coarse step and then bisects it to precision) would give exact shadows at today's default performance.

Happy to test any patch or pre-release build — I have a reproducible benchmark (LOLA terrain + photographic ground truth) for both accuracy and speed.

Side note: I'm currently pinned to 0.19.0 because in 0.19.1 interactive geometry updates appear to apply only once per accumulation cycle, which breaks smooth time-lapse animation in my app; if that was an intentional change I'd appreciate a flag to restore the 0.19.0 behavior, since I'd need it to adopt a fix for this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions