Catalog / Technique
Image lid
Use when drawing a WebGL effect over an image on a page: an arrival, a treatment that lifts, a hover, a scroll response. Covers the lid, a pooled canvas laid over an img that is never touched, the bitmap rule, the handover, the rescue, the row rule, the driven form with its rest test, and where lids may and may not go.
- Loads as
technique-image-lid- Written for
- An agent, to follow
Image lid
One sentence: the real img is never hidden and never moved; an effect
is one opaque canvas laid over it for as long as the effect runs, drawn
from a copy of the image made at the canvas’s own pixel size; taking
the canvas off is the rest state and every failure path.
When to use
- A picture that arrives through something only a shader does: a rough front with material behind it, a dissolve pixel by pixel, cells that resolve.
- A gallery, a listing, a card grid: pictures that come into view.
- A treatment the CSS engine cannot make: one sample a cell, whole round dots, a dithered reveal.
Do not use it for:
- An image in view at load. An arrival is for what comes in; covering what is already there withholds it.
- A figure inside running prose. Nothing moves beside the text unless the reader starts it.
- Images with transparency, or on a ground that is not flat. The lid is opaque.
- Anything the CSS engine already does well enough. A context is the scarce thing.
Rules
- Never hand an
imgtotexImage2D. It froze the page for a tenth of a second or more per image. Make anImageBitmapoff the main thread, cropped to whatobject-fitshows and resized to the lid’s pixel size, when the image is near; upload that, which takes 4 to 12 ms. - The texture is made at the lid’s size. A full-size texture minified with linear filtering does not match the picture (13 percent of pixels off by more than 2 levels, small text broken up). A bitmap resized to the lid matched to within a few levels.
- Size the backing store from
devicePixelContentBoxSizewhere it exists, with rounding as the fallback, and fade the lid on and off rather than cutting: on fractional pixel ratios a rounded lid was off by up to 103 levels on a tenth of its pixels. Cancel every animation on a canvas when the pool takes it back and again when it lends it. The fade that takes a lid off fills forwards; left on the canvas, it held the next picture’s lid at opacity 0: a lid on, drawing, that nobody could see. On the owner’s bench it read as pictures answering at random, and a check that counts canvases cannot see it; the checks read the canvas’s computed opacity. - Snap the pixel-ratio cap to a whole fraction of device pixels. A lid pixel that is 1.33 device pixels smears every cell.
- Draw the first frame before the canvas is seen. Insert it hidden, read the tokens, draw, then show it. The first frame is the treated picture or the ground, never nothing.
- The lid is opaque and paints the ground. Because the image under it is never hidden, nothing the script does can leave a hole; the worst outcome is a lid that has to come off.
- Wind a rescue animation back every frame. A compositor opacity
animation, delayed by
rescueMs, takes the lid off by itself if frames stop coming. Measured: lids gone 3.1 s into a 7 s block. - Cover one lid per frame, page-wide. A cover costs 4 to 35 ms of main thread.
- A row arrives whole or arrives plain. Images that enter together are grouped by row; the first row takes the lids there are, a later row that cannot be covered whole arrives plain. A row half treated was seen in the research and is worse than either.
- A bitmap that comes after its image entered still covers it, late and faded on. On a 2x screen or a slower machine some of a row’s bitmaps resolve after a fast scroll has brought the row in; dropping those left one plain tile among treated neighbours, at random. Two things follow. Entering never waits on “near”: the two observers report in no promised order, and inside an iframe the root margin that makes near come first is not applied, so a row jumped to was entered first, turned away, and never asked again. And an image is in the waiting group once: handed in at entry and again when its bitmap came, a row of three counted as six, more than the lids there are, and was left plain for good.
- Pool the contexts. Sixteen per page in Chrome and Safari, eight on Android, shared with every other tab of the site. Lend a canvas for the length of an effect, give it back, lose its context after four idle seconds. Never above six live.
- On context loss, drop; never restore. The lid comes off, the entry is dropped, the next effect makes a new one.
- Stop on
beforeprint, on hiding, on the image’s resize, and on any exception in the loop. All of them remove every lid of the mount. - Never add
crossorigin. It turns a working image into a broken one when the server is not set up for it. Upload insidetry; an image that cannot be read is left plain and never tried again. - Once. Each image arrives once per page load, the first time it is seen. Not on scrolling back.
- A lid driven by input ends on a rest test in device pixels, never on a timer. The pointer is gone, or the page is under its dead zone, and the largest displacement is under half a device pixel. A threshold on the drive itself kept lids on for seconds showing nothing. Its first frame is the plain picture, so it goes on invisibly; it answers every time, not once.
- Scroll is read, never written. No smooth-scroll library, no
wheel handler, no
preventDefault, no transformed wrapper. The speed comes from the scroll events themselves, differenced between consecutive events, so a flick after a long rest is measured from its own first two events. A test holds the file to it. - Mouse and pen only. A touch pointer starts nothing: there is no hover. And presence is read from the pointer’s last known position against the picture’s box every frame, never from an enter and leave pair: a leave that never comes kept a lid present for good. A cancel, a blur or an exit of the window is the pointer gone.
- A hover lid goes only on a thumbnail that is a link, where a hover means “about to open”. Never on a picture the reader inspects: the pointer rests on exactly the thing being read. A lid driven by scroll speed is off by default on a real page: a lid per visible picture, for a reader who is scanning.
- A lid recipe declares its own
VERTEXanddraws. The region compiles the declared vertex shader and callsdraws(gl, at, options)for the first frame and every frame after; most recipes draw one triangle, a point recipe draws the ground then its points. - A lid recipe says whether a picture in view at mount arrives.
COVERS_IN_VIEWis false for a gallery recipe, where an arrival is for what comes in and a flick produces none, and true for a hero recipe, whose picture is on screen at load.
Decisions
| Question | Choose | Because |
|---|---|---|
| One tracking canvas with hidden images, or a lid per image? | The lid | The other way alters scrolling on every page, hides the images when it fails, and costs 120 kB or a hand-written equal. The owner saw it and said no |
| How many lids live at once? | 6, the ceiling, by default; 3 where the pictures are full width; 2 for a hover | The cost was measured on full-size lids: eight ran at 21 fps on integrated graphics, four were fine. Six tiles of a grid are fewer pixels than one of those. A row that cannot be covered whole arrives plain, so a cap under the row count shows on half the gallery |
| Pixel-ratio cap? | 2 | 1.5 halves memory and softens the picture for the length of the effect on the screens most likely to notice |
| Duration of an arrival? | About 500 ms of visible sweep, with a gentle ease | Past half a second a transition reads as delay |
| Colour split? | Zero on screenshots; a toggle | Garish on type; on a photograph it may suit |
| More images than lids? | The whole row plain | Per-image fallback gives ragged rows |
| Where the lid core lives | Repeated in each lid recipe as a harness region, held by harness-drift |
A recipe cannot import, and a mode switch would hide three effects behind one name |
| Driven by a clock, or by input? | A clock for an arrival or a treatment; the driven form for a hover or a scroll response | Two regions, lid and lid-driven: the driver and the rest test replace the clock, the entering observer and the row grouping, and everything else is the same text |
| How many lids for a hover? | 3, and a hover outranks a release tail | The one under the pointer, one letting go, one spare. A cap of two left the third card of a sweep plain, because a release takes over a second to reach rest; when the pool is full the lid left longest ago gives up its canvas |
Tuning
| Dial | Start | Range | One end | The other |
|---|---|---|---|---|
| Arrival duration (ms) | 500 | 300 to 900 | A flash of texture | A loading screen |
| Band (share of the box) | 0.28 | 0.1 to 0.5 | A thin barcode of smear at the edge | Half the picture swims |
| Stretch | 0.2 | 0 to 0.4 | A clean organic wipe | Psychedelic warping |
| Rough | 0.1 | 0 to 0.25 | A straight edge | A torn, blobby edge |
| Frequency | 5 | 2 to 14 | One or two slow lobes | A fussy, noisy edge |
| Split | 0 | 0 to 0.15 | None | Rainbow fringes on type |
| Stagger within a row (ms) | 70 | 0 to 120 | Together | One after another |
| Hover radius (share of the short side) | 0.28 | 0.14 to 0.5 | A dent that reads as a fault | The whole card slides |
| Hover drag | 0.06 | 0.03 to 0.14 | Barely there | A smear across the card |
| Hover bulge | 0.03 | 0 to 0.1 | Felt in motion, unseen in a still | A fisheye |
| Hover attack, release, follow (ms) | 160, 450, 90 | 100 to 300, 250 to 900, 50 to 200 | Snaps, cuts off, stuck to the cursor | Lags, wobbles on, syrupy |
| Flick amount (share of the height) | 0.03 | 0.012 to 0.08 | Soft focus | The picture sags; text smears |
| Flick dead zone (px/s) | 250 | 150 to 600 | Wobbles on a reading scroll | Only a hard flick shows it |
Ranges are from the research’s stills. Motion has not been tuned by eye.
Rest state
The plain img, with no canvas in the document. The same under reduced
motion (nothing is created), without WebGL2 or createImageBitmap, on
a software renderer, when the pool is spent, when the image cannot be
read, in print, and after the effect.
Cost
- To build: the lid once, medium: most of it is the failure paths. Each effect on top is a fragment of under forty lines and a few uniforms.
- To run, measured on one integrated GPU (AMD Radeon 610M, Chrome
154): a new lid costs about 35 ms of main thread for a 1440 by 900
one (
getContext18 ms median, 55 worst, compile and link 4 ms, upload 12 ms), a pooled one 4 to 12 ms; per frame while live, 0.1 to 0.2 ms of script for four to eight lids. Memory per live lid at 720 by 450 CSS px on a 2x screen: about 21 MB; capped at 1.5, about 12. Nothing after the handover. The lid region is about 3 kB gzipped of each recipe that carries it. - To the reader: the picture is covered or distorted for the length of the effect. On an arrival that is a delay before it can be read; under a pointer it is worse, which is why a hover may only go on a thumbnail that is a link.
Checklist
- The budget check passes and
harness-driftholds the lid region. -
npm run check:browser: lids go on, at most the cap at once, and no picture under one is hidden or moved. -
npm run check:browser: at the end the lid matches the picture to within 8 levels on under 2 percent of pixels. -
npm run check:browser: with the loop stalled the lids are gone inside 3.5 s. -
npm run check:browser: a row is covered whole or not at all. -
npm run check:browser: reduced motion makes no bitmap; a cross-origin image is left plain;beforeprintand a lost context take lids off. - Looked at once on the stage at phone size, and once with the 30 fps preview.
- Run once in Safari (no
devicePixelContentBoxSize), once in Firefox, and once on a 125 percent Windows screen before it is marked passed.
Recipes
shader-lift: a treatment lifted off a picture, pixel by pixel.plane-arrive: a rough front uncovers a picture from the edge it entered by.point-lift: a picture arrives as its own points; the lid form with a point draw.plane-press: the driven form, carrying its region; a linked thumbnail answers the pointer.plane-bend: pictures trail their edges while the page moves fast; a lab experiment.
Sources
mdn-webgl-textures: why a cross-origin image cannot be a texture, and the trap of addingcrossoriginbcd-createimagebitmap: support for the bitmap resize that makes the handover exactwebgl2fundamentals-multiple-views: why contexts are the scarce thingchromium-review-android-contexts: the cap of eight on Androidnng-animation-duration: where a transition starts to read as delay