Reflet · reference
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.
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
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.
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 image | In the room | Direction |
|---|---|---|
| centre of the disc | zenith, overhead | (0, 1, 0) |
| bottom of the frame | the front of the dome — what the audience faces | (0, 0, −1) |
| top of the frame | behind their heads | (0, 0, 1) |
| left of the frame | the viewer's left | (−1, 0, 0) |
| right of the frame | the viewer's right | (1, 0, 0) |
| edge of the circle | the 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.
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);
}
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.
| Model | Frame | Use |
|---|---|---|
equidistante | square, 1:1 | The dome master. What a planetarium expects. |
fisheye | square, 1:1 | Same family, law of your choosing. To match a real lens. |
equirectangulaire | 2:1 | 360 video — and the easiest source to port elsewhere. |
cubemap | 3:2, 3×2 cross | Six raw faces, no projection loss. |
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.
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.0precision 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 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.
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)); }
GLSL TOP, paste the code into its glsl1_pixel.uField (180) and uTilt (0).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.
A .jxs file run by jit.gl.slab. Same mathematics, Jitter syntax.
<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(
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.
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.
| Pitfall | Symptom | Remedy |
|---|---|---|
| Non-square frame | The disc becomes an ellipse. Reads as “a slightly wide field”. | Exact output resolution, letterboxing computed — never left to the window's shape. |
| Colour space | The 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 mirroring | Left 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 corners | The projector shows whatever the browser compositor decided: grey, white, the interface. | Outside the circle: opaque black, alpha = 1. Never alpha = 0. |
| Fog at the seams | Six 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 elements | A shape jumps at the seams: six faces, six orientations. | Hide sprites. Planes oriented once in world space cross the cube map unchanged. |
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 face | px / degree | Memory | Verdict for a 2048² master |
|---|---|---|---|
| 512 | 4.5 | 6 MB | Illegible. Shapes only. |
| 1024 | 8.9 | 25 MB | Below the original perspective view (15.4). |
| 1536 | 13.4 | 57 MB | Sufficient. The default. |
| 2048 | 17.9 | 101 MB | Comfortable. |
| 4096 | 35.7 | 403 MB | For a 4096² master. Heavy. |
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.
| Field | Status | Where the horizon falls |
|---|---|---|
| 180° | Standard delivery | r = 1.000 — the rim |
| 210° | Delivery, domes reaching the floor | r = 0.857 |
| 220° | Render margin, not a delivery format | r = 0.818 |
| 360° | Light probe / angular map | r = 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.
| Name | Pixels | Note |
|---|---|---|
| 1K | 1024 × 1024 | Small domes, prototyping. |
| 2K2K | 2048 × 2048 | The common format. “2K2K” names the side of the square. |
| 3K | 3200 / 3600 | Two entries on the IMERSA list; “3K” itself is not standard. |
| 4K | 4096 × 4096 | In 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.
# 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.