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.
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.
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.htmlNo install, no build, no npm dependencies.
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.
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.
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.
| 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.
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.
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.
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.
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.
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. |
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.
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.
MIT. See LICENSE.

