← Help

Reflet · reference

Dome projection

How to use this installation's dome output: from a browser at a dedicated address, or by taking the shader into TouchDesigner or Max/Jitter. The whole geometry fits on one line — but the mistake you can make in it is invisible exactly where you would look for it.

The projection law

A dome master is a square image holding a circle tangent to its edges. What defines the projection is how the angle from the zenith becomes a radius in the image. Four laws exist. Only one is the standard.

Each disc carries the same parallels — 15°, 30°, 45°… from the zenith — placed by its own law. Look at the centre: all four are identical. Look at the rim: they are not. That is the whole difficulty of dome work. You check an image in the middle, everything looks right, and the error shows up in the room — on a medium you cannot correct afterwards.

// θ = angle from the optical axis   r = normalised radius, 0 at centre, 1 at the rim
// F = TOTAL field (180° for a classic dome, 210° for a dome reaching the floor)

theta = r * F / 2;          // equidistant — IMERSA 2019, what a dome expects
theta = 2*asin(r*sin(F/4)); // equisolid — what real fisheye lenses do
theta = asin(r*sin(F/2));   // orthographic — horizon crushed against the rim
theta = 2*atan(r*tan(F/4)); // stereographic — angles preserved, areas not
The error costs 86 pixels

Between equidistant and equisolid the gap peaks at 0.042 in normalised radius, around θ ≈ 51.6°. On a 4096 × 4096 master that is 86 pixels. It is zero at the centre, zero at the rim, and maximal halfway out. No check made at the centre of the image will ever see it.

Orientation

It is not intuitive, and getting it wrong turns the audience around. The IMERSA 2019 specification is explicit: “bottom of the frame representing the front bottom of the dome screen, and the right and left hand sides […] corresponding with the respective right and left sides of the dome to a viewer sitting at dome center”.

In the imageIn the roomDirection
centre of the disczenith, overhead(0, 1, 0)
bottom of the framethe front of the dome — what the audience faces(0, 0, −1)
top of the framebehind their heads(0, 0, 1)
left of the framethe viewer's left(−1, 0, 0)
right of the framethe viewer's right(1, 0, 0)
edge of the circlethe horizon at 180°; below it beyondθ = F/2

Counter-intuitive consequence: turning towards the viewer's right reads anticlockwise in the image. That is the classic inversion of looking upward — the map is flipped. A move that “goes right” on your screen goes left in the room.

Tilt

A tilted dome (30°, 45°) or an upright one (90°, the iDome case) is produced by rotating the direction vector around the left–right axis, before projection. No 2D transform of the image produces this — attempting one gives a plausible, wrong picture.

Check the sign before anything else: at 90° the centre of the frame must show the front, the bottom the nadir, the top the zenith. With the opposite sign an upright dome shows the audience what is behind them — a mistake this page itself carried until 17 October 2026, invisible at the default 0°.

vec3 tilt(vec3 d, float t) {
  float c = cos(t), s = sin(t);
  return vec3(d.x, c*d.y + s*d.z, -s*d.y + c*d.z);
}

From a browser

The shortest path: an address, a browser in full screen, a projector. No shader to write.

The addresses are not written on this page, on purpose: a dome address is reachable by whoever holds it, and announced nowhere. The administrator finds them in the admin panel, in the Fulldome card — one per open model, shown once the settings are saved, ready to copy onto the projection machine. Every space has its own.

A model left unchecked in the admin panel refuses its address: 404, as if it did not exist. Saying “closed” would already teach that it does.

Full screen lives in the address and not in the settings because it belongs to the machine that projects: the booth runs full screen while the control desk watches the same thing in a window, at the same second. The same card can append ?plein=1 to the addresses it gives. A browser refuses to enter full screen without a user gesture — the page arms it, says so on screen, and the first click is enough.

ModelFrameUse
equidistantesquare, 1:1The dome master. What a planetarium expects.
fisheyesquare, 1:1Same family, law of your choosing. To match a real lens.
equirectangulaire2:1360 video — and the easiest source to port elsewhere.
cubemap3:2, 3×2 crossSix raw faces, no projection loss.
The frame must not deform — check it

The page forces the exact output resolution and computes the letterboxing itself. If you embed it elsewhere (iframe, capture, a wrapper of your own), never impose a width and height that break the model's ratio. A disc stretched onto 16:9 stays perfectly plausible: it reads as a slightly wide field. In the dome it is unrecoverable. The one deliberate exception is Stretch to window, below: it is meant for capture tools that remap the window themselves.

On the page: the four corners

The settings of the projection sit in the four corners of the window. On a dome master the corners lie outside the disc — black, never projected; in a window wider than the image they fall in the letterbox. They appear when the mouse moves and fade after four seconds of stillness. Press H to hide them completely (for a capture), and again to bring them back. The mouse pointer stays visible.

CornerSettings
top left — ImageStretch to window; horizontal and vertical stretch; test pattern; calibration grid (meridians, parallels, horizon — its starting value is the admin panel's); mode 3D or 2D; text size; extent of the crown; rotation speed.
bottom left — CentreThe layout (crown or oriented audience), then where real time sits: an XY pad (the disc drawn inside it) and two number boxes, or the audience's gaze and the band of elevation.
top right — LightBrightness, contrast, colour of each time level, cumulative levels.
bottom right — SoundStart the sound, volume, focus, orientation of the sound field.

The composition

Under the dome, the real-time contribution sits at the centre of the frame (the zenith, unless you move it) and the other time levels surround it in rings, from the most recent to the oldest. Every text has its top towards the centre: a spectator reads upright the texts in front of them, whichever way they face. The crown turns slowly around the centre, so that every text passes in front of every part of the audience; the text at the centre turns with it. Rotation at 0 stops it.

Move the centre off the middle and the crown stretches to fill the visible dome: in every direction around the new centre it reaches as far as the edge of the dome, scaled by the extent. It lengthens towards the empty side and tightens towards the near edge; nothing is left empty, nothing falls outside the field.

Oriented audience

The second layout is for theatres where the whole audience looks one way (tiered, tilted domes). Contributions are placed as in the headset: real time straight ahead of that gaze — 45° of elevation by default —, the recent levels around it, the older ones towards the edges, spread over the dome, and nothing above a maximum elevation (70° by default) nor below a minimum (5°). Every text takes for its top the top of the audience's head, brought into the plane of the text: it stands upright for whoever looks in the common direction, as on a screen. The crown does not turn. Settings: azimuth and elevation of the gaze, maximum and minimum elevation, and the opening in azimuth (360 = all around the dome).

3D: the levels sit at different depths and drift slowly, as in the headset — relief, and the tunnel when travelling in time. 2D: everything lies on the vault at the same distance, without drift; each level keeps its apparent size.

The XY pad is expressed in dome-master coordinates (−1 to +1, bottom = front of the dome). It follows the projection law and the tilt exactly: the centre lands where you put it on the image, also at mid-radius, where the laws differ most.

Stretch to window

Planetarium capture tools grab a window and remap it onto their own dome master: they expect an image that fills what they capture. With Stretch to window the image fills the window, without letterbox, at the window's own pixel size — the disc becomes an ellipse on screen, and the capture tool makes it round again. Unchecked, the frame keeps its exact proportions. Like full screen, this belongs to the machine that captures: ?deformer=1 imposes it from the address, and ?reglages=0 starts with the corners hidden.

Stretch, squeeze, and the test pattern

Dome software does not all capture the same way: some expect an image squeezed or stretched along one axis. Stretch H and Stretch V (0.5 to 2) stretch (> 1) or squeeze (< 1) the whole output; whatever leaves the frame is black.

The test pattern draws a square, its inscribed circle and an isosceles right triangle with its corner mark. It is drawn in the frame of the image — not on the screen — so it undergoes exactly what the image undergoes: stretch, Stretch to window, then the capture and remapping of the dome software. At the end of the chain the square must be square, the circle round and the right angle right; if not, adjust the stretch until they are.

What is kept, and where

Nothing is sent to the server. These settings live in this browser, for this space only, so the booth and the control desk can differ at the same second. Only what you change is kept: a default changed later in the admin panel (level colours, cumulative levels) still applies wherever nobody touched it. Reset forgets this machine's settings.

Sound

A browser makes no sound without a gesture: the Start the sound button is that gesture. Under the dome the sound field is fixed to the room — dragging the mouse no longer turns it; Orientation aligns its front with the front of the dome. Each voice follows its text as the crown turns. Focus at 0 gathers every voice at the centre of the ring (diffuse, everywhere); at 1 each voice sits at the distance of its level.

The shader

Two versions. The first is the one this installation runs, sampling a cube map. The second starts from an equirectangular image — that is the one to port, because TouchDesigner and Jitter handle a 2D texture far more easily than a cube map.

source: samplerCube · GLSL ES 1.0
precision highp float;
varying vec2 vUv;
uniform samplerCube uEnv;
uniform float uField;   // TOTAL field, in radians
uniform float uTilt;    // 0 = zenithal, PI/2 = upright dome

vec3 tilt(vec3 d) {
  float c = cos(uTilt), s = sin(uTilt);
  return vec3(d.x, c*d.y + s*d.z, -s*d.y + c*d.z);
}

void main() {
  vec2 p = vUv * 2.0 - 1.0;
  float r = length(p);
  // outside the circle: OPAQUE BLACK, never transparent
  if (r > 1.0) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }

  float theta = r * uField * 0.5;        // EQUIDISTANT
  float phi   = atan(p.y, p.x);

  // image frame: +z = centre of the disc = zenith
  vec3 dImg = vec3(sin(theta)*cos(phi), sin(theta)*sin(phi), cos(theta));
  // into scene space: bottom of the frame = front of the dome
  vec3 dir = vec3(dImg.x, dImg.z, dImg.y);

  gl_FragColor = vec4(textureCube(uEnv, normalize(tilt(dir))).rgb, 1.0);
  // then re-encode linear -> sRGB. See the pitfalls below.
}
source: equirectangular 2:1 · portable
// The only difference is the last step: instead of sampling a cube map, the
// direction is turned into equirectangular coordinates. Everything else is identical.
// Convention: the CENTRE of the equirectangular image is the FRONT (-z). That is what
// 360 players and ffmpeg use.

vec2 equirectUV(vec3 d) {
  float lon = atan(d.x, -d.z);            // -PI .. PI, 0 = front
  float lat = asin(clamp(d.y, -1., 1.));  // -PI/2 nadir .. PI/2 zenith
  return vec2(lon / 6.28318530718 + 0.5, lat / 3.14159265359 + 0.5);
}

void main() {
  vec2 p = vUv * 2.0 - 1.0;
  float r = length(p);
  if (r > 1.0) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }

  float theta = r * uField * 0.5;
  float phi   = atan(p.y, p.x);
  vec3 dImg = vec3(sin(theta)*cos(phi), sin(theta)*sin(phi), cos(theta));
  vec3 dir  = tilt(vec3(dImg.x, dImg.z, dImg.y));

  gl_FragColor = vec4(texture2D(uSrc, equirectUV(normalize(dir))).rgb, 1.0);
}

Check your shader at three points. r = 0 must give (0,1,0), the zenith. The bottom of the frame (φ = −90°, r = 1, F = 180°) must give (0,0,−1), the front. The left of the frame must give (−1,0,0). Those three values catch every orientation mistake, and you can verify them on paper.

TouchDesigner

Without a shader

TouchDesigner converts on its own. Bring the equirectangular output into a Movie File In TOP (or via capture / NDI), then a Projection TOP set Input: Equirectangular → Output: Fish-Eye. Adjust the field. Shortest path, and it is correct — with one reservation: confirm the law it applies is equidistant before you project in a room.

With the shader

A GLSL TOP, one equirectangular input, two custom parameters. TouchDesigner differs from plain GLSL on three points: inputs are sTD2DInputs[], coordinates are vUV.st, and output goes through TDOutputSwizzle().

uniform float uField;  // degrees — custom parameter
uniform float uTilt;   // degrees — custom parameter
out vec4 fragColor;
const float PI = 3.14159265359;

vec3 tilt(vec3 d, float t) {
  float c = cos(t), s = sin(t);
  return vec3(d.x, c*d.y + s*d.z, -s*d.y + c*d.z);
}

void main() {
  vec2 p = vUV.st * 2.0 - 1.0;
  float r = length(p);
  if (r > 1.0) { fragColor = TDOutputSwizzle(vec4(0.0, 0.0, 0.0, 1.0)); return; }

  float theta = r * radians(uField) * 0.5;
  float phi   = atan(p.y, p.x);
  vec3 dImg = vec3(sin(theta)*cos(phi), sin(theta)*sin(phi), cos(theta));
  vec3 dir  = normalize(tilt(vec3(dImg.x, dImg.z, dImg.y), radians(uTilt)));

  float lon = atan(dir.x, -dir.z);
  float lat = asin(clamp(dir.y, -1.0, 1.0));
  vec2 uv = vec2(lon/(2.0*PI) + 0.5, lat/PI + 0.5);

  fragColor = TDOutputSwizzle(vec4(texture(sTD2DInputs[0], uv).rgb, 1.0));
}
  1. Drop a GLSL TOP, paste the code into its glsl1_pixel.
  2. Vectors tab: add uField (180) and uTilt (0).
  3. Common tab: Resolution set to Custom and square — 2048 × 2048. This is where deformation is decided.
  4. Connect the equirectangular source to input 0.
  5. Set the display TOP's Fill Mode to Fit Best, never Fill.
The TouchDesigner trap

A non-square resolution on the GLSL TOP produces an ellipse, exactly as on the web. And a Fill Mode left on Fill produces one at display time even when the TOP is square. Two places, the same mistake, and the image stays plausible in both.

Max / Jitter

A .jxs file run by jit.gl.slab. Same mathematics, Jitter syntax.

reflet.dome.jxs
<jittershader name="reflet.dome">
  <description>Equirectangular to equidistant dome master</description>
  <param name="tex0"  type="int"   default="0" />
  <param name="field" type="float" default="180." />
  <param name="tilt"  type="float" default="0." />
  <language name="glsl" version="1.2">
    <bind param="tex0"  program="fp" />
    <bind param="field" program="fp" />
    <bind param="tilt"  program="fp" />
    <program name="vp" type="vertex">
      <![CDATA[
      varying vec2 uv;
      void main() { gl_Position = ftransform(); uv = gl_MultiTexCoord0.xy; }
      ]]>
    </program>
    <program name="fp" type="fragment">
      <![CDATA[
      varying vec2 uv;
      uniform sampler2DRect tex0;
      uniform float field, tilt;
      const float PI = 3.14159265359;

      void main() {
        // sampler2DRect: coordinates are in PIXELS, not 0..1
        vec2 size = vec2(textureSize(tex0, 0));
        vec2 p = (uv / size) * 2.0 - 1.0;
        float r = length(p);
        if (r > 1.0) { gl_FragColor = vec4(0., 0., 0., 1.); return; }

        float th = r * radians(field) * 0.5;
        float ph = atan(p.y, p.x);
        vec3 dI = vec3(sin(th)*cos(ph), sin(th)*sin(ph), cos(th));
        vec3 d  = vec3(dI.x, dI.z, dI.y);

        float t = radians(tilt), c = cos(t), s = sin(t);
        d = normalize(vec3(d.x, c*d.y + s*d.z, -s*d.y + c*d.z));

        float lon = atan(d.x, -d.z);
        float lat = asin(clamp(d.y, -1., 1.));
        vec2 st = vec2(lon/(2.*PI) + 0.5, lat/PI + 0.5);

        gl_FragColor = vec4(texture2DRect(tex0, st * size).rgb, 1.);
      }
      ]]>
    </program>
  </language>
</jittershader>
[jit.gl.videoplane dome @transform_reset 2]
        |
[jit.gl.slab dome @file reflet.dome.jxs @adapt 0 @dim 2048 2048]
        |
[jit.gl.texture @dim 4096 2048]   // the equirectangular source

// parameters, as messages to the slab:
[param field 180(
[param tilt 0(
The Jitter trap

Jitter works in sampler2DRect by default: texture coordinates are in pixels, not 0…1. A shader copied from elsewhere, assuming 0…1, samples a single corner pixel — and the output comes out one flat colour. That is the first symptom to recognise.

Second: @adapt 0 @dim 2048 2048 on the jit.gl.slab is mandatory — and @dim alone does nothing. Jitter only honours dim when adapt is switched off, and adapt defaults to 1. Set the size without clearing adapt and the output silently inherits the input's 2:1 shape: the disc becomes an ellipse, exactly the failure this line was meant to prevent.

Six pitfalls

Every one of them produces a plausible image. That is what makes them expensive: nothing signals the error, and you find it in the room.

PitfallSymptomRemedy
Non-square frameThe disc becomes an ellipse. Reads as “a slightly wide field”.Exact output resolution, letterboxing computed — never left to the window's shape.
Colour spaceThe dome image is noticeably darker than the direct render. The projector gets blamed.Re-encode linear → sRGB in the remap pass. WebGL engines force linear on non-XR targets.
X mirroringLeft and right swapped. In the dome, it is the audience that is reversed.Do not copy a skybox shader. Engines mirror X for a loaded cube map, not for a rendered one.
Transparent cornersThe projector shows whatever the browser compositor decided: grey, white, the interface.Outside the circle: opaque black, alpha = 1. Never alpha = 0.
Fog at the seamsSix bright lines draw the cube's edges.Fog is computed on view-space Z, not radial distance. Remove it during the cube pass.
Camera-facing elementsA shape jumps at the seams: six faces, six orientations.Hide sprites. Planes oriented once in world space cross the cube map unchanged.

Angular resolution

The one number that decides whether text will be legible in the room. It cannot be judged on a flat screen.

A cube face of N pixels covers 90°, but its density is lowest at its centre: the tangent projection stretches the edges. Density is therefore N / 114.6 pixels per degree, not N / 90.

available density = cubeFace / 114.59155902616465   // N / (2 · 180/π)
required density  = width / fieldDegrees

// example: a 2048² master at 180° needs 11.4 px/degree
//          so cube faces of 11.4 × 114.6 ≈ 1304 px
Cube facepx / degreeMemoryVerdict for a 2048² master
5124.56 MBIllegible. Shapes only.
10248.925 MBBelow the original perspective view (15.4).
153613.457 MBSufficient. The default.
204817.9101 MBComfortable.
409635.7403 MBFor a 4096² master. Heavy.
And antialiasing disappears

A cube-map target cannot be multisampled — not in this engine, not in most. The direct render is. On a work made of fine moving text the difference shows: jagged edges, shimmer. The only compensation is supersampling, meaning larger faces. That is why the cube-face setting is not a comfort slider.

Reference

Fields of view in use

FieldStatusWhere the horizon falls
180°Standard deliveryr = 1.000 — the rim
210°Delivery, domes reaching the floorr = 0.857
220°Render margin, not a delivery formatr = 0.818
360°Light probe / angular mapr = 0.500

A 220° master played as-is on a 180° dome is compressed by 18%: the player maps the edge of the circle to the springline, without knowing and without saying so.

Standard resolutions

NamePixelsNote
1K1024 × 1024Small domes, prototyping.
2K2K2048 × 2048The common format. “2K2K” names the side of the square.
3K3200 / 3600Two entries on the IMERSA list; “3K” itself is not standard.
4K4096 × 4096In a dome, 4K is square — not 4096 × 2160.

The inscribed circle occupies π/4 of the square: 21.5% of the file's pixels — the four corners — are never projected. IMERSA has the title, timecode and copyright put there.

ffmpeg chain

# pitch=90 is MANDATORY. Without it, v360 renders a FRONTAL fisheye —
# horizontal axis, perpendicular to the dome axis. On abstract content the
# difference does not show on a flat screen. It shows in the dome.

ffmpeg -i input.mp4 -vf \
  "v360=e:fisheye:ih_fov=360:iv_fov=180:h_fov=180:v_fov=180:pitch=90:w=4096:h=4096" \
  domemaster.mp4

# 210° dome: h_fov=210:v_fov=210

No metadata says “this is a dome master”. The Spherical Video V2 specification knows only equi, cbmp and mshp — no fisheye, no dome. A dome master tagged equi would be read wrong. The right behaviour is to tag nothing: the format declares itself by its 1:1 ratio, its filename, and the readme beside it.

The geometry above is checked automatically: the five cardinal points of the orientation are evaluated from the shader itself rather than copied. What nothing checks is what the image looks like in an actual room. That needs a dome.