This live walkthrough is new. Please report bugs or issues to max1@mit.edu.

Constructing Printable Surfaces with View-Dependent Appearance

Maxine Perroni-Scharf, Szymon Rusinkiewicz

Princeton University · SIGGRAPH 2023 Conference Proceedings

Design tool

Design your own view-dependent surface

Pick up to four viewing directions and an image for each. Press Optimize to run the optimizer from the paper, then download the result as a printable mesh. The settings below are the arguments of grad_descent().

It runs in your browser. We ported the code from Python to JavaScript for this page. There is also a full-page version of the tool.

Each image is turned to face its camera, so it looks upright from that view. The Python code does not do this.

Pick views and pictures, then press Optimize.

loss per view height steps bars split rays doubled

Optimizer settings (grad_descent arguments)

After step 300 the bars are not split any further. The Python code keeps splitting them every 100 steps.

In short

We make 3D-printed surfaces that show a different picture depending on where you look from. The surface is a grid of coloured bars of different heights. From any direction, taller bars hide parts of the bars behind them, so each viewer sees a different mix of colours.

Such a surface is hard to design by hand, so we optimize it. We use a differentiable renderer for coloured heightfields. It reduces each camera ray to a 1D problem with a closed-form colour, so gradients are cheap to compute. The optimizer adjusts heights and colours until each view matches its target image. The result is printed directly on a full-colour PolyJet printer, with no lenses or coatings.

Section 3

A differentiable renderer for heightfields

A heightfield is a matrix of bar heights H and a matrix of bar colours C. It is viewed by orthographic cameras, each given by a viewing direction. This is a good model because the surfaces are shallow. For every camera i there is a desired image Di, and the renderer produces the actual image Ii.

Each pixel is a ray. In the code, the pixels lie on a grid over the surface, at its mean height, and each ray is traced back along the camera direction.

Diagram: heightfield H, C viewed by two cameras producing images I1, I2 compared with desired D1, D2
Setup for two views (from the paper).

Slicing the heightfield

A ray can only hit the bars directly below its path. So we project the ray onto the ground plane, list the bars it crosses in order, and measure how far it travels through each one. This gives a 2D slice: a row of bars with heights hi, colours ci and widths wi, and a 2D ray that enters it at height oy.

Interactive: drag the white pixel or the orange camera handle. Numbers are bar heights.
pixel colour

The code: find_intersections, find_ids_and_lengths, get_2D_ray

  
Python From rendering.py. pixel and cam come from the figure above.
# traced back from z = 1, as in grad_descent.py
origin = pixel - 20 * cam
ids, widths, ray = rendering.get_2D_ray_info(origin, cam, 8)

print("bars crossed (row, col):", [(int(a), int(b)) for a, b in ids])
print("widths:", np.round(widths, 3))
print("o_y:", round(float(ray.origin.y), 3))
JavaScript The same steps in JavaScript. You can edit and run this code. It also reruns when you move the figure.

    

Rendering a single pixel in closed form

For each bar, trace a line back from its far top corner, parallel to the ray, to the line x = 0. It meets that line at height

yi = hi − (dy / dx) · Wi,    Wi = w0 + … + wi (1)

where (dx, dy) is the direction of the 2D ray, which points down. A ray entering at height oy passes over bar i when oy > yi. A tall bar can hide the bars behind it, so we take a running maximum y′i = max(y0, …, yi). With the step function g, the colour of the ray is a telescoping sum:

color(p) = c0 + Σi g(oy − y′i) · (ci+1 − ci)

Each bar the ray passes over moves the colour on to the next bar. The sum ends at the first bar that blocks the ray, or at the last bar if none does.

Interactive: drag bar tops to change heights, click a bar to change its colour, drag the black dot (oy) up and down.
this side
other side

The two strips on the right show what all parallel rays see from this side and from the other side. The same bars show different colours from each side. “Show gradients” draws purple arrows that show how each height should move to bring the pixel closer to a target colour you pick.

The code: Eq. 1, the running maximum, and the colour sum

  
Python The same pixel, rendered with rendering.py. H and COL are the heights and colours from the slicing figure. ids, widths and ray come from the cell above.
width_sums = rendering.get_width_sums(widths)
thetas = rendering.get_backtraced_boundaries(H, ray, width_sums, ids)  # Eq. 1
mono = rendering.monotonize_boundary_points(thetas)  # running maximum
color = rendering.render_ray_given_boundaries(ray, mono, COL, ids, heaviside_fn="tan")

print("y_i      :", np.round(thetas.numpy(), 3))
print("monotonic:", np.round(mono.numpy(), 3))
print("o_y      :", round(float(ray.origin.y), 3))
print("pixel colour (RGB):", np.round(color.numpy(), 3))
JavaScript The same pixel in JavaScript. ray is the slice from the cell above.

    

Rendering all pixels, and repeating units

A camera image is made of many such rays. The slices depend only on the camera and the bar grid, not on the heights, so the code computes them once (process_rays) and reuses them at every step. For repeated patterns, the heights of a smaller unit are optimized and tiled across the surface. In the code, the heights repeat 2×2, while every bar has its own colour.

Python The full 16×16 image seen by the camera in the slicing figure, rendered with render_camera.
n = 16
origins = [np.array([(i + .5) / n, (k + .5) / n, 1.0]) - 20 * cam for i in range(n) for k in range(n)]
packs = [rendering.process_rays(origins, cam, 8)]
image = rendering.render_camera(H, COL, packs, 1, num_repeats=1, heaviside_fn="tan")[0]
JavaScript The same image in JavaScript.

      
    

Section 4

Objective function

We minimize the pixel-wise mean squared error between the rendered and desired images of all cameras. With m×m images:

minH,C   1/(m² |CAM|) · Σi Σx,y ( Di(x,y) − forward(cami, H, C)(x,y) )²

The loss values on this page are this error, per view.

Smooth Heaviside approximations

The step function g has zero slope almost everywhere, so the heights would get no gradient. We replace it with a smooth approximation λ(x, k), where k sets the amount of smoothing. In our tests, tanh worked best, so it is the default. The forward pass still uses the exact step, so the rendered image stays exact. Only the backward pass uses the smooth version.

Interactive: the five approximations (solid) and the derivative of the selected one (dashed). The dotted black line is the exact step.
Heaviside approximations (Table 2 of the paper)
approximationλ(x, k)
circle½ + ½ · x / (x² + k²)½
circle distanceBern(½ + ½ · x / (x² + k²)½)
erfc½ + ½ erf(kx)
tanh½ + ½ tanh(kx)
logBern(½ + ½ tanh(kx))
Convergence curves for the Heaviside approximations
Convergence with each approximation (from the paper): erfc and tanh outperform the others.
Python happrox.py: the forward pass of every approximation is the exact step.
x = torch.tensor([-1.0, -0.1, 0.0, 0.1, 1.0])
for name, fn in [("tanh", happrox.heavi_tanh_fn), ("erfc", happrox.heavi_erfc_fn), ("circle", happrox.heavi_circ_fn)]:
    print(f"{name:6s} forward:", fn(x, 0.1))
JavaScript The same in JavaScript.

    
The code: HeaviTanh (forward and backward)

  

Regularization

Two extra terms keep the surface printable. A barrier loss keeps every height between hmin and hmax. A neighbour loss penalizes height differences between adjacent bars, which avoids thin spikes and deep gaps:

barrier = −Σi log(hmax − hi) − Σi log(hi − hmin),    neighbour = Σi Σj ∈ N(i) |hi − hj|

Regularization costs some accuracy (Table 1), but it is needed for surfaces that survive printing. The original code (v1.0) uses hmin = 0.1, hmax = 1.5, and weights 0.02 (barrier) and 0.5 (neighbour). The updated code (v2.0) uses hmax = 1.0. Its neighbour loss also compares bars across the edges of the repeating unit, which avoids spikes along the seams.

MSE after 100 steps, two views (Table 1 of the paper)
camera 1camera 2nonebarriersmoothingboth
blackwhite0.0660.0920.0870.136
blackrandom0.0860.1050.1270.133
blackstripes0.1560.1620.1680.169
randomstripes0.1980.2490.2090.203
randomrandom0.1160.1310.1130.124
Python opt.py on the heights from the slicing figure, with the v1.0 height range.
print("barrier  :", round(float(opt.barrier_fn(H, min_val=0.1, max_val=1.5)), 4))
print("neighbour:", round(float(opt.neighbor_fn(H)), 4), "  v2.0:", round(float(opt.neighbor_fn_wrap(H)), 4))
spiky = H.clone()
spiky[3, 4] = 1.45  # one tall spike
print("with a spike:", round(float(opt.barrier_fn(spiky, 0.1, 1.5)), 4), round(float(opt.neighbor_fn(spiky)), 4), round(float(opt.neighbor_fn_wrap(spiky)), 4))
JavaScript The same in JavaScript.

      

Section 5

Optimization

The optimizer is Adam on the MSE loss, with three additions (Table 3 of the paper):

After optimizing, the target images are projected back onto the surface from their cameras. This adds detail finer than the bars. Each point is coloured from the cameras that see it, weighted by how directly they see it. To try it, press “2 · Project” in the design tool. This also trims the surface to a thin base, and the download contains the printable mesh.

Settings of the code (v2.0), also used on this page
stepsettingwhere
initial surfaceuniform heights 0.5, white colours; height unit 4×4 tiled 2×2 (8×8 bars)optimize_surface.py
lengthnum_epochs = 295, i.e. 294 gradient steps (the design tool runs 600 by default)optimize_surface.py
simulated annealing50 steps before gradient descent, T = 3 cooled ×0.99 per step, perturbations up to ±0.025simannealing.py
coarse to finebars split 2×2 at steps 100 and 200 (8×8 → 16×16 → 32×32); rays per view doubled 5 steps earlier (16² → 32² → 64²)grad_descent.py
block coordinate descentrepeating 50-step pattern: 10 colour steps, 10 height steps, 20 colour steps, 10 height stepsgrad_descent.py
optimizerAdamW; learning rate 0.05 for colours, 0.03 for heightsgrad_descent.py
camera raysstart on a plane at the current mean surface height, re-centred every 10 stepsgrad_descent.py
projectionsurface sliced thin (shortest bar 0.05 tall); desired images projected onto the visible segments of the bars, blended by how directly each camera sees them; closed textured mesh (.obj, .mtl, .png)reproject.py
lossMSE + 0.02 · barrier(0.1, 1.0) + 1.0 · neighbour (every bar against its four neighbours, wrapping around the repeating unit, averaged over bars); smooth step: tanh with k = 0.1grad_descent.py
The code: the schedule in grad_descent.py

    

    

  
The code: the projection in reproject.py

  
The code: sim_annealing

  
Loss after 100 steps with two views, for each technique (Table 3 of the paper). “All three” combines coarse to fine, coordinate descent and annealing.
camera 1camera 2nonecoarse to finecoordinate descentall three
blackwhite0.1360.0670.1070.011
randomrandom0.1240.0990.1080.093
blackrandom0.1330.1080.0940.051
blackstripes0.1690.1240.1410.037
randomstripes0.2030.2050.1800.057

The paper also compares different starting surfaces (flat, walls, cross, random). The code starts from a flat surface.

Renders of five initial surface configurations
Initial configurations studied in the paper.
Convergence curves for different initial configurations
Convergence for each initial configuration (four views, from the paper).

Sections 6–8

Fabrication and results

For the paper, we turned the heights and colours into a coloured mesh in Blender and printed it on a Stratasys J55 PolyJet printer. The updated code builds the mesh in Python. Test prints showed that 300 dpi prints cleanly, which sets the smallest bar width. For surfaces under 1 cm wide, a clear top layer protects the thin bars. This layer bends light, which lowers the effective viewing angle.

Photographs of printed surfaces from four and two directions
Printed surfaces. Top: one surface seen from four directions (elevation 60°) shows four unrelated patterns. Bottom: the Stanford Dragon and the Stanford Bunny on the same surface (elevation 30°).
Render and photograph of the same surface
Rendered (top) and printed (bottom) versions of the same surface match closely.
High-resolution bike sign surface
A biking / no-biking sign less than 1 cm wide, seen from two directions at 60° elevation.
Dog and cat surface printed at two bar widths
A dog from one side and a cat from the other, printed with wider (top) and narrower (bottom) bars. Narrow bars mix colours across views because the printing material is slightly translucent.

Optimization results

With the PyTorch code, an optimization takes 30 minutes to 2 hours. Five views work less well than four. Two views work well when they differ by more than about 40° in elevation or 60° in azimuth.

Renders of surfaces optimized for five and four views
Optimized surfaces with five (top) and four (bottom) distinct desired views.
Comparison with a lenticular surface
Comparison with lenticular prints at the printer’s highest resolution. Top: target images. Middle: lenticular print. Bottom: our surface, with sharper lines and finer detail, but slightly more colour mixing between views.

Limitations

The method works for up to five views in simulation and four in print. Unlike lenticular prints, quality depends a lot on the angles between the cameras. Lighting and self-shadowing are not modelled. Future work could correct for them by adjusting the colours. On the other hand, the method reaches high resolution, can share colours between views, and uses bright colours, without the dark walls or lens arrays of earlier methods.

Citation

@inproceedings{Perroni-Scharf:2023:CPS,
  author    = "Maxine Perroni-Scharf and Szymon Rusinkiewicz",
  title     = "Constructing Printable Surfaces with View-Dependent Appearance",
  booktitle = "Proc. SIGGRAPH",
  year      = "2023",
  month     = aug
}

We would like to thank Baffour Osei and the Princeton Robotics Lab for their assistance during this project.