← 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.

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 main space
reflet-web.net/dome/equidistante
reflet-web.net/dome/fisheye
reflet-web.net/dome/equirectangulaire
reflet-web.net/dome/cubemap

// a named space — every account can have its own
reflet-web.net/<space>/dome/equidistante

// address options — properties of the MACHINE, not of the installation
?plein=1   // arms full screen; the first click enters it

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. 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 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: EquirectangularOutput: 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.