Laying a flat image down on a 3D map
Design tools only rotate within the canvas plane, so they cannot make a ring lie flat on tilted ground and spin, and they leave no parameters to hand to front-end. I built a three-axis transform tool in the browser; rendered in a page, its exported CSS lands within 0.007px of the tool's own corners.
- CATEGORY
- Tool
- YEAR
- 2026
- STATUS
- Note
- TOOLS
- webgl / css-3d / gif
entryWrite-up
In visual design for data visualization, one kind of request keeps coming up: the center of the screen holds a 3D map with its perspective tuned by hand (a rendered bitmap), and elements such as rings and scan circles sit on top of it; they have to tilt until they lie flat on the map plane while spinning continuously around their own centers. At the design stage this stalls on two things. First, the usual interface design tools cannot do it: rotation on a two-dimensional canvas is a single in-plane angle, with no tilt that tips an element back around the X axis; the tool’s new animation feature, once checked, supports only translate, in-plane rotation and scale, and 3D transforms are rejected outright as unsupported; community plugins can warp perspective, but most of them flatten the element into a bitmap, which can no longer be edited and yields no parameters. Second, even with a finished mock-up, the parameters cannot be handed over: front-end gets a picture, and the tilt angle, the strength of the perspective and the duration of one turn can only be eyeballed and retried. A small three-axis transform tool for vector graphics had been built earlier, and this time it served as the prototype for a version aimed at bitmaps and GIFs.
At first it seemed to be only a matter of rotating an image; halfway through it became clear that three requirements had to hold at once: one, alignment, where a front-facing element has to tilt until it matches the perspective of the base image exactly; two, preview, where after tilting it also has to spin around its own normal, so its motion can be seen; three, handoff, where once the angles, perspective strength and animation parameters go to front-end, what they build from them has to look exactly like what the tool shows. The third came to matter most, because however precisely the design side is tuned, if it distorts in front-end, the earlier work is wasted. So exported CSS is checked by pasting it into a page, running it and measuring, not by whether the code looks right.
The single-image version’s requirement was to import one ordinary image or auto-looping GIF at a time, with controls modeled on the old tool and a fixed background color. Three things were settled before building: export to PNG, GIF and video; keep the code panel and change it to output CSS; add a background, guides and GIF playback controls. The image is drawn as a single quad with WebGL (the browser’s low-level 3D graphics interface). The rotation order is Z, Y, X, which is exactly equivalent to CSS rotateX() rotateY() rotateZ() read left to right, so exporting CSS needs no conversion. Perspective uses homogeneous coordinates on the vertices, that is, each vertex stores one extra number for its distance from the camera, and the GPU uses it to correct for near-large, far-small when filling pixels, so the tilted texture is not warped. GIFs are decoded frame by frame with the browser’s native ImageDecoder; because the runtime does not allow loading external scripts, the encoder is hand-written.
Once the first version was done, still images needed per-axis animation too, for example a ring imported with X fixed at a 15° tilt and Z spinning continuously. For this, rotation was split into two layers, a “base angle” plus “per-axis animation on top”, and animation supports three modes: loop, ping-pong and once.
Next came multiple layers, a layer list and on-canvas dragging, evaluated first, with no code. The first evaluation was heavy, listing layer Z height, automatic depth sorting, independent per-layer animation and more. Spelled out, the real intent was: import a map with existing perspective as the base, import a front-facing element, adjust its X angle to match the base, set it spinning, and hand the parameters to front-end. That one sentence cut the plan in half. The second plan distinguished two layer types, “reference” and “working”; the question of whether there was a more general approach was pressed further, and the final decision was to have no types: a reference layer is just an ordinary layer with all three angles at 0, and with all three angles at 0 the projection is the identity, so the base image’s pixels pass through untouched. Properties are split into two levels. Scene-level properties are globally unique and cover camera distance, projection mode, canvas and background; the camera must be unique, or the layers would not share one space. Layer-level properties exist once per layer and cover the three angles, in-plane offset, opacity and axis animation. A “scene pitch” was added as well: calibrate it once against the base image, store it in the scene, and for every new element imported afterwards, one click on “Match scene pitch” is enough. My own use is a batch of elements on one base image, calibrated once and reused many times, which is where it saves the most work.
The multi-layer version set two usage constraints. First, the base image is only occasional, so importing a single image must look and behave exactly like the single-image version: in the single-layer state, the layer list, scene controls and match button do not appear, only an unobtrusive “+ Add layer”; comparing the output before and after the refactor item by item on the same inputs, the only difference was one comment line describing the size, deliberately added to the exported CSS. Second, when two images are imported in turn, the tool does not guess which is the base. “Base image” is only the user’s concept; the only thing the tool really has to decide is the canvas size, so it gets a sensible default that is visible and one click to change, with no smart detection. The reason is that predictability matters more than cleverness: one wrong automatic detection in an alignment tool throws away all the angles that took a long time to tune.
On-canvas drag alignment needs a coordinate conversion: when the mouse lands on the tilted picture, which point of the image does it correspond to? The image of a plane under perspective projection is described exactly by a 3×3 homography matrix; the screen positions of the layer’s four corners first solve for where each point of the image maps on the screen, and running that relationship in reverse turns screen coordinates back into image coordinates. Hit testing reads the image’s transparency, so a click in the hole in the middle of a ring passes through and selects the base image underneath.
To test how useful the exported CSS is to front-end, the code went as-is into a blank page, and three problems turned up. One, the container collapsed to zero height, the code output no size at all, the children were all absolutely positioned, and the layers hung outside the container. Two, the perspective distance was hard-coded: with the same rotateX(52deg), shrinking the element while the perspective distance stays fixed weakens the trapezoid, the trapezoid ratio drifted from 1.347 to 1.061, and the element looks as if it floats above the ground, while front-end, having filled in the angle as given, would not suspect their own code. Three, preserve-3d makes the browser re-sort by 3D intersection, so a tilted lower layer cuts through the one above, reversing the stacking order, unlike what the tool shows. The export format was revised against these three points: the scene container carries a scale variable --k, and every size and the camera distance are written as calc(value * var(--k)), so front-end changes one number, the whole set scales proportionally and the trapezoid keeps its shape; preserve-3d is gone and the perspective sits directly on the container, where, measured, both forms give the same trapezoid ratio, the perspective is unchanged and stacking follows DOM order, matching the tool; the comments are written for front-end, explaining the HTML skeleton, that DOM order is stacking order, and which parts must not be changed and why.
In actual use, importing a small image and then a large one left the large one cut off, with a “canvas reference” label in the interface. Tracing it showed that the label meant which image the canvas size followed, an intermediate concept the user had to guess at; the same trace also found camera distance defined as “factor × canvas height”, so changing the canvas size silently changed the perspective of every layer, invalidating angles already aligned, with no warning at all. The fix was to delete “canvas reference” and replace it with two numbers that can be edited directly; the canvas grows automatically when a large image comes in; when the canvas size changes, the camera factor is compensated in reverse so that the absolute camera distance stays the same. Measured with the canvas going from 286×286 to 1150×805 to 1800×1400, the absolute camera distance stayed at 572px. A concept in the interface that makes you ask “what does this mean” is taken as a signal that it should go.
While dragging, the grabbed point drifted away from the cursor, with the error reaching 14 source pixels. The cause was that auto-fit included the layer offset: a larger offset shrank the view, the shrink changed the homography, and that formed a feedback loop. After auto-fit was changed to compensate only for the spread caused by rotation, unaffected by the offset the user sets, the grab error fell to 0.05 source pixels, all of it from rounding to 0.1px.
The comment in the orthographic export originally read “orthographic projection has no native CSS form”. Measurement showed the comment was wrong: CSS with no perspective set is already orthographic; a 400px-tall element with rotateX(45deg) keeps its top and bottom edges equal and is 282.8427px tall, exactly cos 45° × 400. So the comment was corrected and perspective: none written out explicitly. The line changes no behavior; its job is to state intent: without it the code reads like an oversight, and front-end might add a value, turning orthographic into perspective.
Imported images looked as if they carried a drop shadow. Investigation found a decorative box-shadow on the canvas container, forming a dark halo within 60px below the canvas, 20 levels darker than the background at its darkest. Although exported files were unaffected, in an alignment tool any change in brightness outside the canvas can be mistaken for picture content. It was changed to leave only a 1px border line.
Another, more hidden problem was layers with semi-transparent pixels looking dirty and gray. In a reproduction with a solid color at alpha (opacity) 128, the correct composite should be rgb(131, 94, 39), but the result was rgb(71, 53, 27). Working back from “alpha multiplied twice”, all three channels match, for example red: 240 × 0.502² + 22 × 0.498 ≈ 71.5. The root cause was that bitmaps generated by the browser are premultiplied by alpha by default (color values already multiplied by opacity), yet the texture upload declared them “unpremultiplied”, and the shader multiplied again; transparency was applied twice and colors were darkened once too often. Fully transparent and fully opaque areas were unaffected, and the error grew toward half-transparency, which shows as gray midtones. The fix handles the whole pipeline as premultiplied: premultiply on upload, and the shader no longer multiplies again. This also solved another hazard along the way: texture interpolation now happens in premultiplied space, so when rotating and scaling, color left in transparent pixels no longer bleeds into edges as gray fringes.
With rendering fixed, exported GIFs still showed a clear error. The cause was that GIF has only 256 colors, and the palette was sampled at a fixed stride: dozens of nearly identical frames spent the sampling budget on repeated pixels, and the brightest part, tiny in area, was never sampled. After the strategy was changed to merge two paths, “fixed-stride sampling” and “at least one representative of every color that appears”, the error at the glow center fell from 96 to 5. Before adopting it, a local comparison was made on two typical kinds of material: whole-image mean squared error fell from 5.57 to 1.38 for gradients and from 26.70 to 22.19 for multi-color material; it went in only after both were confirmed to improve.
After the multi-layer refactor, an independent code review was done, with the reviewer seeing only the code, not the development process, and every issue required to give specific line numbers and reproducible steps. The report listed 9 confirmed defects and 6 risks, of which three were most serious: one, animation durations in the exported CSS were wrong, every layer used the scene period, so an element that turns once a second became one turn every 3 seconds in CSS, directly affecting the parameters handed to front-end; two, “once” mode leaked into other layers, so setting one layer to once made every layer’s CSS play only once; three, GIF export cached every frame’s pixels in memory, taking about 4GB at large sizes and high frame rates, with a risk of running out of memory, and after switching to two passes, peak memory is a single frame. All of these were fixed, with regression tests added.
Only while writing up this record did it turn out that in the local offline build with a single image, the “Layer opacity” and “Match” buttons should have been hidden but were shown. The cause was that the online runtime ships a style that makes the hidden attribute take effect, and the earlier local test page had added the same rule, so together they masked the problem. The tool was then made to carry that style rule itself, no longer relying on its environment. Besides, the tests themselves also went wrong during several rounds of correction: once an opaque block was imported onto the upper layer, covering the semi-transparent layer under test entirely; another time auto-fit was left on, so positions sampled by radius did not line up. Each was caught because the measured numbers did not match expectations, so every conclusion rests on measured values.
The finished tool is a single 87KB HTML file that runs offline, with images processed only in local memory. Rendered in a browser, the corners from the exported CSS are within 0.001px of the tool’s internal calculation at --k 1 and within 0.007px at 0.35; the rendering error on semi-transparent pixels fell from 112/255 to 1/255. A ring lying flat on tilted ground and still spinning used to depend on front-end trying angles over and over; now the angle can be tuned against the base image on the design side and the parameters handed over unchanged.
processBefore & after
Verification (all measured)
- CSS render offset
- ≤ 0.001px at --k = 1 · ≤ 0.007px at --k = 0.35
- Orthographic offset
- ≤ 0.0002px
- Drag grab error
- 0.05 source px (14 before the fix)
- Coordinate round trip
- screen ↔ image about 10⁻¹³px
- Output with all angles at 0
- 0 of 28000 pixels differ
- Semi-transparent error
- 1/255 (112 before the fix)
- GIF gradient peak error
- 5 (96 before the fix)
- Absolute camera distance
- 572px before and after canvas changes
Tool features
- Import
- PNG · JPG · WebP · GIF
- Transform
- three-axis rotation · perspective / orthographic projection · per-axis animation
- Layers
- stacked layers · on-canvas drag alignment · scene pitch
- Export
- PNG · GIF · video · CSS in single-layer, multi-layer and orthographic forms
Exported CSS
.scene {
--k: 1; /* placed at 20% in the page: write .2 */
width: calc(832px * var(--k));
height: calc(546px * var(--k));
perspective: calc(1092px * var(--k)); /* camera distance, must scale with --k */
}
.layer-2 {
/* ring */
transform: rotateX(52deg) rotateZ(0deg); /* 52° calibrated against the base */
animation: tilt3d-2 2.4s linear infinite;
}resultTakeaways
- Finding
- Rendered in a browser, the layer corners from the exported CSS are within 0.001px of the tool's internal calculation at --k = 1 and within 0.007px at --k = 0.35; the rendering error on semi-transparent pixels fell from 112/255 to 1/255.
- Parameters
- rotation order Z→Y→X · every size and the camera distance multiplied by --k · no preserve-3d · absolute camera distance held when the canvas is resized
- Open
- Frame-by-frame GIF decoding relies on the browser's ImageDecoder, which works in Chrome and Edge; elsewhere the tool still previews GIFs but cannot step through frames or export GIF or video.
logChangelog
- 2026-10
- First entry