Forward Renderer

Predictable sphere tracing for still images, and StableHLO compilation to WGSL.

cadjoint’s image renderer is designed for predictable forward rendering. It uses early-exit sphere tracing, screen-space silhouette reconstruction, finite-difference normals, Cook–Torrance GGX materials, soft shadows, and optional reflection and refraction rays.

Geometry and constraints remain JAX-native and differentiable. Rendered pixels are intentionally not an inverse-rendering or optimization API — the gradients this project cares about run through meshing and simulation, not through visibility.

Scene and quality settings

Use Scene for camera, lights, and environment data and RenderSettings for the performance/fidelity trade-off:

from dataclasses import replace

from cadjoint.render import Camera, RenderSettings, Scene, render_scene
from cadjoint.sdf.primitives import Sphere

scene = Scene(
    Sphere(1.0),
    camera=Camera(position=(0, 1.5, 5), target=(0, 0, 0)),
)

preview = render_scene(scene, RenderSettings.draft((240, 320)))
final = render_scene(scene, RenderSettings.high_quality((480, 640)))

# Presets are immutable; override only what a scene needs.
mirror = replace(RenderSettings.balanced(), reflect_steps=64)

render_scene returns a (H, W, 3) float array. The low-level raymarch(sdf, ...) function remains available for compact notebook calls, and render_raymarched(...) wraps it to display on a matplotlib axis.

Each primary trace retains its closest approach to the scene. Rays that narrowly miss a surface receive screen-space coverage across RenderSettings.silhouette_smoothing pixels, which removes hard hit/miss outlines without restoring differentiable visibility or fixed-step tracing. Set the value to 0 when exact binary coverage is required.

Rendering modes

All panels below are produced by the forward renderer. Soft shadows stop as soon as they find an occluder, and reflection and refraction rays are enabled only when requested.

Direct lighting, soft shadows, metal reflections, and glass refraction

Quality presets

Preset Trace steps Hit ε Shadow steps SSAA Default resolution
draft 72 1.5e-3 0 1×1 200 × 200
balanced 96 1e-3 32 2×2 200 × 200
high_quality 160 5e-4 64 3×3 400 × 400

draft is direct lighting with no secondary visibility; balanced adds soft shadows and reconstructed silhouettes; high_quality tightens surface precision and doubles the shadow budget. Reflection and refraction are off in all three and must be asked for explicitly.

Draft, balanced, and high-quality forward renders

The presets are starting points rather than hidden modes. Every field is public, validated, and can be changed with dataclasses.replace.

Materials

Attach a Material to any primitive. It is queried per hit point during rendering, and Union blends materials smoothly across a blended boundary.

from cadjoint.render import Material
from cadjoint.sdf.primitives import Sphere

sphere = Sphere(
    radius=1.0,
    material=Material(
        color=[0.22, 0.50, 0.95],
        roughness=0.35,
        metallic=0.0,
        opacity=1.0,
        ior=1.0,
    ),
)

Transparency and refraction

Set opacity < 1 for transparency. With refract_steps=0 (the default) the surface simply fades toward the background. With refract_steps > 0 the renderer performs two-bounce Snell’s-law refraction: the primary ray bends into the material, an interior march finds the back face using −sdf as the distance field, and the ray bends back into air to continue through the rest of the scene. Schlick-Fresnel adds the edge highlight that brightens the rim of a glass sphere at grazing angles.

import jax.numpy as jnp
from cadjoint.render import Material, raymarch
from cadjoint.sdf.boolean import Union
from cadjoint.sdf.primitives import Sphere
from cadjoint.sdf.transforms import Translate

glass = Sphere(radius=1.0, material=Material(
    color=[0.92, 0.97, 1.0], roughness=0.05, opacity=0.04, ior=1.5))
red_ball = Translate(
    Sphere(radius=0.65, material=Material(color=[0.93, 0.26, 0.22])),
    offset=jnp.array([-1.1, 0.5, -3.0]),
)

image = raymarch(
    Union((glass, red_ball), smoothness=0.0),
    camera_pos=jnp.array([0.0, 0.5, 5.5]),
    resolution=(400, 400),
    background_color=jnp.array([0.07, 0.09, 0.16]),
    refract_steps=48,
    max_steps=80,
    aa_samples=2,
)
Material ior
air / disabled 1.0
water 1.33
glass 1.5
diamond 2.42

Shader compilation

The same scene compiles to a standalone shader function. JAX traces distance and material evaluation, lowers both to StableHLO, and cadjoint emits the shader source from there — so the browser renders the identical field the Python renderer does.

from cadjoint.backends import compile_scene_to_wgsl, compile_sdf_to_wgsl
from cadjoint.sdf.primitives import Sphere

wgsl = compile_sdf_to_wgsl(Sphere(radius=1.0))          # just `fn sdf`
scene_wgsl = compile_scene_to_wgsl(Sphere(radius=1.0))  # + materials

compile_scene_to_wgsl emits sdf, material_base (RGB + roughness), and material_optics (metallic + opacity + IOR + reflectivity) from one scene snapshot, ready to embed in a WebGPU renderer. This is exactly what the playground does on every run.

How big the shader gets

A shader is one straight-line function per entry point, so its size is decided by how much of the tree the trace unrolls. Two things keep that in check. A node evaluated more than once — a pattern’s child, a subtree two booleans share — is lowered as its own StableHLO function, and the emitter turns each of those into one WGSL function called once per instance; a bearing housing with four polar patterns is 1.45 MB of WGSL rather than 2.42 MB. What is not shared is the vertex loop of a sketch profile: WGSL has no type wider than a mat4, so a profile’s vertices stay individual vec2s and its contribution grows with the vertex count. (Everything else traces the profile as one (N, 2) array; see the meshing notes.)

Design parameters are inlined as float literals by default, so editing one rewrites the whole module and the browser recompiles it. uniforms=True returns a ShaderProgram instead: the same source for every parameter value, plus a vec4-per-parameter uniform layout to write the values into.

from cadjoint.geometry import Scalar

sphere = Sphere(radius=Scalar(1.0, free=True, name="radius"))
program = compile_scene_to_wgsl(sphere, uniforms=True)
program.wgsl                 # identical across every value edit
program.parameters[0].name   # 'radius' — the name extract_parameters uses
program.buffer()             # float32 array to upload at @group(3) @binding(0)

Further reading

  • Rendering notebook — composition, quality presets, and hot reload
  • The playground — the interactive WebGPU viewport, its progressive path tracer, and its views of the field itself (signed slice, gradient magnitude, iso-offset, normals, depth)
  • WebGPU SDF path tracing — design and deliberate boundaries of the browser path tracer