Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Photophane: a photo, the clear object solved from it, and the picture that object throws on a wall

Photophane

Hide a photo inside a clear 3D print. Shine a light through and it lands on your wall.
Solved in your browser tab. Nothing is uploaded.

Live demo 86 checks passing Zero dependencies No build step MIT license

Try it here

If you print one, post it to r/photophane. A photograph of a real print is the one thing this project does not have.


Print the object, shine a phone torch through it, and the photo appears on the wall behind it. In your hand the object looks like a blank piece of plastic. The picture is stored as surface slope, not as ink or as anything you can see, so it stays invisible until light bends through it.

The technical name for what the object makes is a caustic, and that is what the internals are called. The project is not, because a name nobody recognises is a name nobody clicks.

Run it

npm run dev    # static server on http://localhost:8080
npm test       # 50 solver, 16 interaction and 20 STL checks, roughly 40 s
npm run shots  # regenerate the three images in docs/ from tools/shots.html

No install, no build, no npm dependencies.

How it works

The state is a scalar potential, not a mesh. Light leaving a material of index n through a surface of slope grad(h) is deflected by (n-1) grad(h), so at throw distance D it lands D(n-1) grad(h) away. Write that displacement as grad(psi) and you get h = psi / (D(n-1)) directly, with no integration step and no curl error.

Each iteration compares the brightness the current surface throws against the target on a fixed screen grid, solves a Neumann Poisson problem for the correction potential using multigrid, then line searches along it. Multigrid is not optional here: 20,000 SOR sweeps left a relative residual of 7e-2 where 14 V-cycles reach 1e-12.

Design uses a paraxial deflection model. Every result is then re-checked by tracing exact Snell refraction through the surface that model produced, counting total internal reflection and light that misses the screen. Both numbers are shown. The gap between them is the cost of the approximation, and it is the honest figure to publish.

Contour plan of the relief, a true-proportion section, and the ray path with a brightness comb on the wall

Move the light

Fig. 7 hands you the lamp. The design assumes light arriving in parallel rays, and a visitor has no way to feel what that costs, so the figure lets you break it: a point source at a finite distance, refracted at the flat back face as well as at the relief, retraced live.

Lamp, transparent body and wall in oblique projection, with the projected picture on the wall

Drag the lamp: down moves it away from the body, up brings it close, sideways slides it off axis. The dashed rail shows the travel. The two drag axes are deliberately decoupled and their sensitivities are set rather than inherited from the projection. Tracking the drawn depth axis exactly made vertical motion about sixteen times touchier per pixel than horizontal, because that axis is logarithmically compressed to fit 0.15 m to 30 m onto the sheet. The distance control drives the same value, so the two cannot disagree.

The lamp starts at a finite 1.42 m rather than at the parallel-light stop, so both drag directions have travel from the first gesture. That position measures at 19.8 points of agreement worse than parallel light, against 51.9 at the near stop and 0.6 at the far one, so the wall disagrees with Fig. 3 on arrival without being an unreadable smear. Both bounds are asserted in the suite. Parallel light is the top of the slider, the reset button and the Home key. Earlier it was the default, which meant "further away" had nowhere to go and grabbing the lamp had to teleport it inward before a sideways drag could mean anything.

While a control is moving, the figures retrace at one ray per cell and the numbers beside them hold their last settled value and dim, then a full pass lands 180 ms after you stop. That is deliberate: no figure on this sheet reports a number it measured at preview quality. Measured at the 129 grid, one input event cost 135.6 ms before this and 8.3 ms after.

Measured on a solved blob, agreement against the picture as the lamp recedes:

Lamp distance Error Picture on the wall
300 mm 45.2% 200 mm
1 m 26.3% 130 mm
3 m 11.9% 110 mm
10 m 4.2% 103 mm
Parallel 1.1% 100 mm

Moved sideways instead, the projection becomes a trapezoid and the agreement figure is withheld rather than guessed. That is not squeamishness: a 400 mm sideways offset lowered the apparent error from 21.6 to 9.8 percent purely because the comparison window grew to hold the skew.

What it reports

Quantity Meaning
Agreement with the picture Exact Snell trace against the target. The number that matters.
Design model residual What the paraxial solver believes it achieved.
Model disagreement Gap between the two. The cost of the approximation.
Relief, peak to trough Physical height range of the surface, in mm.
Light leaving the frame Fraction of rays that miss the screen.
Trapped by internal reflection Fraction lost to total internal reflection.

Measured across five targets at 64x64: design error 1.1 to 5.1 percent, exact Snell error 2.1 to 5.5 percent, zero escaped, zero internal reflection, relief 3.6 to 9.4 mm on a 100 mm aperture.

The verdict stamped on Fig. 3 is the first row, banded: under 12 percent is within tolerance, under 25 is marginal, and above that it is a refusal. A shape is still solvable at 40 percent error and would still look plausible on screen, so it is not offered.

Printing one

Print in a clear material and keep the flat face toward the light. You need a small bright source: a phone torch, a spotlight, direct sun. A broad or diffuse lamp gives a blur instead of a picture, because the derivation assumes rays arriving parallel. Set the object at the throw distance shown in the interface, then use a pale wall in a dim room.

The mesh will not look like your picture, and that is not a bug. The image is stored as surface slope, so a viewer shows a shallow smooth field about 11 mm deep across a 100 mm plate, dominated by one broad curve. Every picture produces something that looks like gentle dunes. If it looked like your photo it would be an engraving, and it would not project anything.

Two things make the file checkable anyway. Its name and its 80-byte header both carry the name of the picture it was solved from, so photophane-cat-100mm-throw300mm.stl says what it is without opening it. And npm test parses the written bytes back into a height field, re-traces that by exact Snell refraction, and requires the result to reproduce the source picture to the same figure the interface reported, while scoring far worse against a control image. Measured on the shipped sample: 2.99% from the file against 2.99% in memory, and 73.9% against the control.

Limits

Light can be moved but not created, so dark regions are only as dark as redistribution allows. Pure black is unreachable, and the black floor control sets how dark the picture is allowed to ask for.

The projection is softer than the picture, and mostly for reasons no amount of resolution fixes. A surface that redistributes light rather than blocking it has to borrow brightness from neighbouring regions, and borrowing is smooth, so no single smooth surface produces a hard edge. Measured on a hard two-tone target at a 64 grid: 23 percent error with no softening, 9 percent at one cell, 7 percent at two.

A finer grid does not help. Measured on that same target, a 256 grid was less accurate than 64 (15 percent against 9) and took twenty-five times as long, because finer grids resolve steeper local slopes than the paraxial design model can carry. The grid options stop at 129 for that reason, not to save time. Grid sizes must also be 2^k + 1 for exact multigrid coarsening.

Detail is therefore capped at 128 cells across. A cat's whiskers will not survive; the shape of its head will.

Layout

index.html            page, captions, drafting frame
styles.css            blueprint styling and responsive rules
app/main.js           wiring, solve lifecycle, reported numbers
app/draw.js           figures 1 to 6, 2D canvas only
app/stage.js          figure 7, the movable lamp, in oblique projection
app/solver-worker.js  runs the solve off the main thread
app/stl.js            binary STL writer
app/sample.js         synthetic target so the first view is never empty
src/caustics.mjs      inverse design and exact Snell verification
src/trace.mjs         point-source trace with subdivided rays, for display
src/poisson.mjs       multigrid Neumann Poisson solver
src/target-prep.mjs   greyscale, floor, softening, normalisation
tools/shots.html      source for every image in docs/, so none of them can go stale
tools/shots.mjs       renders those images headless: npm run shots
test/verify.mjs       50 checks including a closed-form Poisson comparison
test/interaction.mjs  16 checks on the lamp's drag mapping and travel, no browser needed
test/stl.mjs          20 checks that the downloaded file is the verified surface
test/png.mjs          tiny PNG reader, so tests use the real sample photograph
CONTRIBUTING.md       how to run it, what would help most, what will be turned down
NOTES.md              why each decision was made, and the bugs that forced it

src/caustics.mjs and src/trace.mjs are deliberately separate code paths. The first is the verification model and never changes to suit the interface; the second is what the page displays. Given identical settings and parallel light the two agree to 1.5e-13 percent, and that check is in the suite. Without it, the figure a visitor looks at would be unverified.

NOTES.md is the interesting file. It records every bug found before the physics was right, including the one where a sign error inverted the entire surface while the self-consistent check happily reported 1.6 percent error.

Credits

Built by Basel Ashraf (@BaselAshraf81).

Other things I have made:

Project What it is
layout-sans Pure TypeScript 2D flex and grid layout engine. Zero DOM, zero WASM. Canvas text selection, Ctrl+F search, hyperlinks and screen reader support. Demo
ProlificTea Community review platform where research participants rate researchers on fairness and pay, with a Chrome extension that surfaces ratings in place
eigendrum Draw a shape, hear its drum eigenmodes. Closest relative of this project
holystitch Google Stitch to React conversion with zero AI tokens
fastemporal Luxon and Temporal in pure Rust, 40x faster, zero GC

If this was useful you can buy me a coffee.

Take part

All free, and between them they decide whether this goes anywhere.

Print one and show the wall r/photophane. Every figure here is computed and nothing has been checked against a physical object, which is stated plainly under Limits. One photograph of a real print, with the source picture and the light you used, is worth more than any code change.
Star the repository With no marketing, no tracking and no budget, stars are the entire mechanism by which anyone else finds this.
Follow along @BaselAshraf81, for whatever gets built next.
Improve it CONTRIBUTING.md lists what would help most, in order of value. No build step and no dependencies, so a clone runs immediately.
Buy the coffee Ko-fi, only if it saved you an afternoon. It is a static page, so it costs nothing to run and stays free either way.

Not a lithophane

Worth saying, because it is the nearest familiar thing and the mechanism is the opposite.

A lithophane blocks light. It is opaque material of varying thickness, and thick regions transmit less, so the image is a shadow cast by absorption. Detail is limited by how finely you can print, and the result is dim, because making a dark region means throwing light away.

A photophane bends light. The material is clear, thickness is nearly constant, and the image is carried entirely by the slope of one surface: every ray is steered sideways to where it is needed. Nothing is absorbed, so the result is bright, and the bright regions are brighter than the incoming beam because light has been moved into them rather than removed from around them. The cost is that a hard edge is unreachable, since a smooth surface cannot redistribute light discontinuously, and that it needs a small bright source rather than any lamp.

Practically: a lithophane looks like your picture when you hold it up. A photophane looks like a blank piece of plastic until light passes through it.

Prior art

The algorithm is published and has been implemented before, as research code you must clone and run: Ferraro's Julia implementation, dylanmsu's C++ port, the Mitsuba inverse rendering tutorial, and EPFL work commercialised as Rayform. What is different here is packaging, not optics. Drop an image into a tab, get an object, nothing leaves the device.

License

MIT. See LICENSE.

About

Hide a photo inside a clear 3D print. Shine a light through and it lands on your wall.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages