Musée Éphémère ← retour à la galerie
Catalogue Raisonné — Document technique

How to hang a painting that doesn't exist.

The Musée Éphémère is a single HTML file, two vendored GSAP scripts, and roughly six hundred lines of GLSL. There are no image files anywhere in the building — every canvas is a fragment shader evaluated in real time, and the gallery itself is a pinned horizontal scroll rig. This catalogue explains each system with the actual code, so you can build your own wing.

Nº 0

Art direction — the quiet wall

Museums are quiet machines: warm walls, dark frames, brass plaques, one pool of light per work. The palette stays inside that logic — gallery bone for the wall, ink for frames and text, terracotta as the single voice allowed to speak, brass for the furniture of information. As you walk the six salles, the wall hue drifts a few degrees cooler and back — the architectural equivalent of a key change.

Type does three jobs and never trades them: Marcellus is the engraving (wordmark, room numbers), Cormorant Garamond italic is the artwork's voice (titles, curator notes), and Inter in letter-spaced small caps is the plaque metadata. Body copy on this page is Cormorant — a catalogue should read like a catalogue.

Gallery bone#EDE6DA
Warm plaster#E3D9C6
Ink#20201D
Terracotta#B4552D
Muted brass#9C7A3C
Salle IV
MarcellusWordmark, room numbers, headings. One weight; it is an engraving, not a family.
Fumée retenue
Cormorant GaramondItalic for titles and curator prose — the voice of the works.
Signed distance fields, 2026
InterSmall caps, wide tracking. Plaque metadata and interface labels.
Nº I

The rig — walking sideways by scrolling down

The gallery is one flex row (#track) inside a 100vh section. GSAP's ScrollTrigger pins the section and maps vertical scroll distance 1:1 onto a horizontal translation of the track. Because the mapping is 1:1 (end: '+=' + distance), a wheel tick, a scrollbar drag and a pointer drag all move the visitor the same honest distance — no magic ratios to tune.

index.html — the pinned traversalvertical in, horizontal out
// distance the wall extends past the viewport
const getScrollDist = () => Math.max(1, track.scrollWidth - innerWidth);

st = gsap.to(track, {
  x: () => -getScrollDist(),
  ease: 'none',                    // the scrub IS the easing
  scrollTrigger: {
    trigger: '#gallery',
    start: 'top top',
    end: () => '+=' + getScrollDist(),
    pin: true,
    scrub: 1.15,                   // ~1s of inertia between finger and wall
    invalidateOnRefresh: true,
    onUpdate(self){
      // normalized signed velocity → shader uniform + skew
      velTarget = gsap.utils.clamp(-1.6, 1.6, self.getVelocity() / 2600);
      progressGlobal = self.progress;
    }
  }
});

Dragging is the same system wearing gloves. A pointer drag simply writes scrollTo(0, dragScroll - dx) — because the mapping is 1:1, dragging the wall 300px moves it exactly 300px. On release, the last pointer velocity keeps feeding scrollBy with a 0.94 decay per frame: museum momentum, not carnival physics. A dragMoved > 8px guard decides whether a gesture was a walk or a click on a painting.

Nº II

The painting engine — one canvas per work, on purpose

Each artwork owns its own small <canvas> and WebGL1 context rather than sharing one fullscreen canvas with scissored viewports. The trade was deliberate: six contexts is far below browser limits, each canvas is small (a framed painting, not a wall), pausing a work is trivial (just skip its draw call), and the canvases ride the DOM transform of the track for free — no per-frame rect synchronisation. The classic shared-canvas technique earns its complexity when you have dozens of views; a museum hangs six.

Every painting is the same vertex shader (one fullscreen triangle) plus a fragment shader assembled from three parts: a shared prelude (hash, value noise, five-octave fbm), the work's own vec3 art(vec2 uv, float t), and a shared main wrapper that applies velocity distortion, grain, and gallery-light vignette to all works equally — the house style.

index.html — engine corecompile once, draw forever
function makePainting(canvas, fragBody){
  const gl = canvas.getContext('webgl', {
    antialias: false, alpha: false, depth: false,
    powerPreference: 'low-power'       // a museum hums, it doesn't roar
  });
  const prog = makeProgram(gl, PRELUDE + fragBody + MAINWRAP);
  // one triangle covers clip space; no index buffer, no quads
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
  const DPR = Math.min(devicePixelRatio || 1, 1.75);  // resolution cap
  ...
}

Each work keeps its own clock. Per frame: p.t += dt * (0.28 + p.awaken * 1.15). Asleep, a painting moves at ~a quarter of real time — museum stillness. As it approaches the viewport centre (or is hovered, or contemplated), awaken eases toward 1 and the painting breathes faster and zooms in ten percent. Time never jumps; the same clock feeds the contemplation view, so the enlarged work continues mid-thought.

Nº III

The six works — one technique each

SALLE I

Fumée retenue

fbm smoke & marble

Classic folded fbm: one noise field feeds the coordinates of a second, and a sine wave ridden through the result becomes marble veining. pow(abs(vein), 0.55) sharpens the veins without hard edges.

GLSL — salle Ithe fold
float f1 = fbm(p + vec2(t*0.05, -t*0.02));
float f2 = fbm(p*1.4 + f1*2.3 + vec2(-t*0.03, t*0.04));  // noise through noise
float vein = pow(abs(sin(p.y*2.2 + p.x*0.7 + f2*7.0)), 0.55);
vec3 col = mix(ink, bone, vein);
SALLE II

L'encre qui se souvient

domain-warped ink

Inigo Quilez's double domain warp: two vector fields q and r, each built from fbm of the other's output. Ink pools where the warp collapses (smoothstep on a third fbm of p + r).

GLSL — salle IIcoordinates displaced by their own noise
vec2 q = vec2(fbm(p + t*0.04), fbm(p + vec2(5.2,1.3) - t*0.03));
vec2 r = vec2(fbm(p + 3.0*q + vec2(1.7,9.2) + t*0.02),
              fbm(p + 3.0*q + vec2(8.3,2.8) - t*0.025));
float f = fbm(p + 3.2*r);       // the painting
SALLE III

Verrière pour personne

voronoi stained glass

A 3×3 Voronoi search tracks the two nearest feature points; F2 − F1 is the distance to the cell border, which becomes the lead came. Each pane's colour is a hash of its cell id fed through a five-colour palette, breathing at its own phase.

GLSL — salle IIIlead is a distance, colour is a name
float d = length(g + o - fp);
if (d < f1){ f2 = f1; f1 = d; id = ip + g; }
else if (d < f2){ f2 = d; }
...
float lead = smoothstep(0.015, 0.11, f2 - f1);  // border width
vec3 col = mix(cameDark, pane(hash21(id)) * breath, lead);
SALLE IV

Colonie lente

metaballs, RD-style halos

Six inverse-square fields sum into one scalar. A smoothstep at 1.0 births the bodies; sin(field × 16 − t) masked to a band around the threshold produces the standing-wave halos that read as reaction–diffusion.

GLSL — salle IVchemistry impersonated by geometry
field += r*r / max(dot(p - c, p - c), 1e-4);   // per ball
float m = smoothstep(0.9, 1.05, field);        // the body
float rings = sin(field * 16.0 - t * 0.9);      // the halos
rings *= smoothstep(1.6, 0.9, field) * smoothstep(0.35, 0.8, field);
SALLE V

Trois poids de rouge

colour fields, breathing edges

Three reds separated by two horizontal edges whose positions are fbm of (x, time) — so the boundary wanders along its length and drifts over minutes. Edge widths pulse at different tempos; the painting is always exhaling somewhere.

GLSL — salle Vthe edge is the subject
float e2 = 0.62 + (fbm(vec2(uv.x*3.1 + 7.0, t*0.075)) - 0.5) * 0.10;
float w2 = 0.06 + 0.03 * cos(t * 0.33);        // breathing width
col = mix(col, top, smoothstep(e2 - w2, e2 + w2, uv.y));
SALLE VI

Nature morte au globe

raymarched-lite still life

A 40-step sphere-traced scene: one fbm-displaced sphere, one ground plane. Shading is a diffuse dot product, a Blinn highlight at power 42, and a cubed fresnel rim in brass. Small canvas + capped steps keeps it cheap enough to hang beside five siblings.

GLSL — salle VIforty honest steps
for (int i = 0; i < 40; i++){
  float d = map(ro + rd * dist, t);
  if (d < 0.0025){ hit = 1.0; break; }
  dist += d * 0.9;                // slight under-step: fbm bends the field
  if (dist > 6.0) break;
}
Nº IV

Velocity glass — the signature move

While the visitor moves, every painting smears slightly along the direction of travel and its colour channels part — like looking at oil paint through old glass in motion. The effect lives in the shared main wrapper, so all six works obey the same physics. The trick is restraint: the offset maxes at 1.6% of the canvas, channels are re-averaged with a motion-blur mix, and everything settles the moment you stop, because the uniform decays at 0.9 per frame and is smoothed at 0.09 — glass and light, not glitch.

GLSL — shared wrapperruns on all six works
float v = clamp(u_vel, -1.6, 1.6);
if (abs(v) > 0.004){                      // uniform branch: free when still
  vec2 off = vec2(0.016, 0.0) * v;
  vec3 ca = art(uv - off, u_time);        // red looks behind
  vec3 cb = art(uv,       u_time);        // green stays
  vec3 cc = art(uv + off, u_time);        // blue looks ahead
  col = vec3(ca.r, cb.g, cc.b);
  col = mix(col, (ca + cb + cc) / 3.0, min(abs(v) * 0.5, 0.45));
}

The same smoothed velocity drives the DOM: frames skew up to 4.5° against the direction of travel and lead the motion by 18px while plaques lag at 7px — three parallax depths (frame, plaque, wall text) that make the wall read as a place rather than a strip. When the wall stops, everything returns to plumb with the museum easing curve, cubic-bezier(0.16, 1, 0.3, 1).

Nº V

Conservation — a museum that pauses itself

Six live shaders could be a space heater. They aren't, because the building only renders what you can see:

index.html — the sleeping wingrender only the visible
const io = new IntersectionObserver(entries => {
  for (const e of entries){
    const w = paintings.find(p => p.el === e.target);
    if (w) w.visible = e.isIntersecting;
  }
}, { rootMargin: '25%', threshold: 0 });

// in the rAF loop:
if (p.painter && p.visible) p.painter.draw(p.t, velSmooth, p.awaken);
Nº VI

Replication — build your own wing

  1. Vendor GSAP 3.12 + ScrollTrigger locally (cdn.jsdelivr.net/npm/gsap@3.12.5). Load the fonts — Marcellus, Cormorant Garamond, Inter — from Google Fonts.
  2. Lay the wall: a 100vh section containing a flex row of panels (entrance text, six salles, colophon). Add the floor as a ::before gradient and the baseboard as a 1px ::after running the full track.
  3. Pin the section with ScrollTrigger, translate the track by scrollWidth − innerWidth, scrub ≈ 1.15. Feed self.getVelocity() / 2600 into a smoothed uniform.
  4. Write the painting engine: WebGL1, one fullscreen triangle, shader = shared prelude + per-work art() + shared wrapper. Cap DPR, skip draws for off-screen works.
  5. Write six art() functions — steal the six above, then betray them: different palettes, different tempos. Keep every time coefficient under 0.1; stillness is the luxury.
  6. Hang the furniture: dark frames with layered box-shadows, cream mats, brass plaques with an inset rule, picture lights as radial-gradient cones, one bench.
  7. Add the contemplation view: a fixed overlay with one persistent GL context that lazily compiles each work's program, shares the work's clock, and types its plaque character by character.
  8. Respect the visitors: prefers-reduced-motion gets the vertical museum; no WebGL gets the apology card; touch gets native drag. Then hang your name in the colophon and open the doors.

Everything ships from one origin: two GSAP files (~115 KB) and this HTML. No build step, no bundler, no image requests. The whole museum weighs less than one photograph of a museum.