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
- Document
scene_epsilon (it already works via set_float, which is great).
- 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.
Environment
Summary
Geometry rendered with
GeomAttributeProgram.DisplacedSurfacereports 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 approximatelywhich becomes very visible at grazing illumination.
I found that the offset is controlled by the (apparently undocumented)
scene_epsilonvariable, settable viart.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 to1/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:Output on my machine:
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):
With
scene_epsilonreduced ~10x the shadows match reality (verified against photographs), but rendering is ~9x slower, which hurts interactive use.Request
scene_epsilon(it already works viaset_float, which is great).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.