Skip to content

Commit 0afe3a6

Browse files
mvaligurskyMartin Valigursky
andauthored
Add fisheye projection support for skybox (#8577)
* feat: add fisheye projection support for skybox Extends fisheye projection to the skybox so sky distortion matches Gaussian splat distortion when using wide-angle/fisheye rendering. Made-with: Cursor * fix: gate SKY_FISHEYE define on SKYTYPE_INFINITE Avoids compiling unused fisheye shader variants for box/dome sky types. Made-with: Cursor --------- Co-authored-by: Martin Valigursky <mvaligursky@snapchat.com>
1 parent 51cffe3 commit 0afe3a6

9 files changed

Lines changed: 288 additions & 38 deletions

File tree

examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,27 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
4747
{ v: 'industrial-sunset', t: 'Industrial Sunset' },
4848
{ v: 'partly-cloudy', t: 'Partly Cloudy' },
4949
{ v: 'moonlit', t: 'Moonlit Sky' },
50-
{ v: 'night-sky', t: 'Night Sky' }
50+
{ v: 'sunflowers', t: 'Sunflowers' },
51+
{ v: 'table-mountain', t: 'Table Mountain' },
52+
{ v: 'cloud-layers', t: 'Cloud Layers' },
53+
{ v: 'night', t: 'Night Sky' }
5154
]
5255
})
5356
)
5457
),
5558
jsx(
5659
Panel,
5760
{ headerText: 'Camera' },
61+
jsx(
62+
LabelGroup,
63+
{ text: 'Camera Frame' },
64+
jsx(BooleanInput, {
65+
type: 'toggle',
66+
binding: new BindingTwoWay(),
67+
link: { observer, path: 'cameraFrame' },
68+
value: observer.get('cameraFrame') || false
69+
})
70+
),
5871
jsx(
5972
LabelGroup,
6073
{ text: 'FOV' },

examples/src/examples/gaussian-splatting/lod-streaming.example.mjs

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,31 @@ const ENV_PRESETS = {
8686
},
8787
'industrial-sunset': {
8888
url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/industrial_sunset_puresky_2k.hdr',
89-
exposure: 0.3
89+
exposure: 0.8
9090
},
9191
'partly-cloudy': {
9292
url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/kloofendal_48d_partly_cloudy_puresky_2k.hdr',
9393
exposure: 0.9
9494
},
9595
'moonlit': {
9696
url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/qwantani_moon_noon_puresky_2k.hdr',
97-
exposure: 0.2
97+
exposure: 0.4
98+
},
99+
'sunflowers': {
100+
url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/sunflowers_puresky_2k.hdr',
101+
exposure: 0.8
102+
},
103+
'table-mountain': {
104+
url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/table_mountain_2_puresky_2k.hdr',
105+
exposure: 1
106+
},
107+
'cloud-layers': {
108+
url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/cloud_layers_2k.hdr',
109+
exposure: 1
98110
},
99-
'night-sky': {
111+
'night': {
100112
url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/qwantani_night_puresky_2k.hdr',
101-
exposure: 0.06
113+
exposure: 0.2
102114
}
103115
};
104116

@@ -224,18 +236,48 @@ assetListLoader.load(async () => {
224236
focusPoint: focusPoint
225237
});
226238

239+
// CameraFrame for HDR linear rendering (created lazily on first enable)
240+
/** @type {pc.CameraFrame|null} */
241+
let cameraFrame = null;
242+
243+
const applyToneMapping = () => {
244+
const tm = data.get('toneMapping');
245+
if (cameraFrame?.enabled) {
246+
cameraFrame.rendering.toneMapping = tm;
247+
cameraFrame.update();
248+
} else {
249+
camera.camera.toneMapping = tm;
250+
}
251+
};
252+
253+
data.set('cameraFrame', false);
254+
data.on('cameraFrame:set', () => {
255+
if (data.get('cameraFrame')) {
256+
if (!cameraFrame) {
257+
cameraFrame = new pc.CameraFrame(app, camera.camera);
258+
cameraFrame.rendering.toneMapping = data.get('toneMapping');
259+
}
260+
cameraFrame.enabled = true;
261+
cameraFrame.update();
262+
} else if (cameraFrame) {
263+
cameraFrame.destroy();
264+
cameraFrame = null;
265+
}
266+
applyToneMapping();
267+
});
268+
227269
const applyFov = () => {
228270
const fov = data.get('cameraFov');
229271
camera.camera.fov = (data.get('fisheye') === 0) ? Math.min(fov, MAX_PERSPECTIVE_FOV) : fov;
230272
};
231273
data.on('cameraFov:set', applyFov);
232274
data.on('fisheye:set', () => {
233-
app.scene.gsplat.fisheye = data.get('fisheye');
275+
const fisheye = data.get('fisheye');
276+
app.scene.gsplat.fisheye = fisheye;
277+
app.scene.sky.fisheye = fisheye;
234278
applyFov();
235279
});
236-
data.on('toneMapping:set', () => {
237-
camera.camera.toneMapping = data.get('toneMapping');
238-
});
280+
data.on('toneMapping:set', applyToneMapping);
239281
data.on('exposure:set', () => {
240282
app.scene.exposure = data.get('exposure');
241283
});

src/scene/gsplat-unified/fisheye-projection.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,20 @@ class FisheyeProjection {
118118
this.invK = 1.0 / k;
119119
this.cornerScale = 1.0 + (Math.SQRT2 - 1.0) * t;
120120

121-
// Projection-dependent values derived from the camera's projection matrix
122-
const halfFovX = Math.atan2(1.0, p00);
121+
// Projection-dependent values derived from the camera's projection matrix.
122+
// Compute X and Y scales independently to avoid the 0/0 singularity at 180° FOV
123+
// and to correctly handle the non-linear fisheye mapping for non-square aspect ratios.
123124
const maxTheta = Math.min(k * Math.PI / 2, 3.13);
124-
const effHalfFov = Math.min(halfFovX, maxTheta - 0.01);
125-
const gFov = k * Math.tan(effHalfFov / k);
126-
const pm00 = this.cornerScale / gFov;
125+
const cs = this.cornerScale;
126+
127+
const halfFovX = Math.atan2(1.0, p00);
128+
const effHalfFovX = Math.min(halfFovX, maxTheta - 0.01);
129+
this.projMat00 = cs / (k * Math.tan(effHalfFovX / k));
130+
131+
const halfFovY = Math.atan2(1.0, p11);
132+
const effHalfFovY = Math.min(halfFovY, maxTheta - 0.01);
133+
this.projMat11 = cs / (k * Math.tan(effHalfFovY / k));
127134

128-
this.projMat00 = pm00;
129-
this.projMat11 = pm00 * p11 / p00;
130135
this.maxTheta = maxTheta;
131136
}
132137
}

src/scene/scene.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,7 @@ class Scene extends EventHandler {
770770
}
771771

772772
destroy() {
773-
this._resetSkyMesh();
773+
this._sky.destroy();
774774
this.root = null;
775775
this.off();
776776
}

src/scene/shader-lib/glsl/chunks/skybox/frag/skybox.js

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ export default /* glsl */`
1313
varying vec3 vViewDir;
1414
uniform float skyboxHighlightMultiplier;
1515
16+
#if defined(SKY_FISHEYE) && !defined(SKYMESH)
17+
uniform float fisheye_k;
18+
uniform float fisheye_invK;
19+
uniform float fisheye_projMat00;
20+
uniform float fisheye_projMat11;
21+
uniform mat4 matrix_view;
22+
uniform mat3 cubeMapRotationMatrix;
23+
varying vec3 vClipXYW;
24+
#endif
25+
1626
#ifdef SKY_CUBEMAP
1727
1828
uniform samplerCube texture_cubeMap;
@@ -42,28 +52,46 @@ export default /* glsl */`
4252
4353
#else
4454
45-
#ifdef SKY_CUBEMAP
55+
// --- compute view direction ---
56+
57+
#if defined(SKY_FISHEYE) && !defined(SKYMESH)
58+
59+
vec2 ndc = vClipXYW.xy / vClipXYW.z;
60+
float px = ndc.x / fisheye_projMat00;
61+
float py = ndc.y / fisheye_projMat11;
62+
float r = sqrt(px * px + py * py);
63+
float theta = fisheye_k * atan(r * fisheye_invK);
64+
float sinT = sin(theta);
65+
float cosT = cos(theta);
66+
vec3 camDir = (r > 1e-6)
67+
? vec3(px / r * sinT, py / r * sinT, -cosT)
68+
: vec3(0.0, 0.0, -1.0);
69+
vec3 dir = transpose(mat3(matrix_view)) * camDir;
70+
dir = dir * cubeMapRotationMatrix;
4671
47-
#ifdef SKYMESH
72+
#elif defined(SKY_CUBEMAP) && defined(SKYMESH)
4873
49-
// get vector from world space pos to tripod origin
50-
vec3 envDir = normalize(vWorldPos - projectedSkydomeCenter);
51-
vec3 dir = envDir * cubeMapRotationMatrix;
74+
// get vector from world space pos to tripod origin
75+
vec3 envDir = normalize(vWorldPos - projectedSkydomeCenter);
76+
vec3 dir = envDir * cubeMapRotationMatrix;
5277
53-
#else
78+
#else
5479
55-
vec3 dir = vViewDir;
80+
vec3 dir = vViewDir;
5681
57-
#endif
82+
#endif
83+
84+
// --- sample environment ---
85+
86+
#ifdef SKY_CUBEMAP
5887
5988
dir.x *= -1.0;
6089
vec3 linear = {SKYBOX_DECODE_FNC}(textureCube(texture_cubeMap, dir));
6190
6291
#else // env-atlas
6392
64-
vec3 dir = vViewDir * vec3(-1.0, 1.0, 1.0);
93+
dir *= vec3(-1.0, 1.0, 1.0);
6594
vec2 uv = toSphericalUv(normalize(dir));
66-
6795
vec3 linear = {SKYBOX_DECODE_FNC}(texture2D(texture_envAtlas, mapRoughnessUv(uv, mipLevel)));
6896
6997
#endif

src/scene/shader-lib/glsl/chunks/skybox/vert/skybox.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ uniform mat3 cubeMapRotationMatrix;
77
88
varying vec3 vViewDir;
99
10+
#ifdef SKY_FISHEYE
11+
varying vec3 vClipXYW;
12+
#endif
13+
1014
#ifdef PREPASS_PASS
1115
// when skydome renders depth during prepass, generate linear depth
1216
varying float vLinearDepth;
@@ -35,9 +39,20 @@ void main(void) {
3539
#else
3640
3741
view[3][0] = view[3][1] = view[3][2] = 0.0;
38-
gl_Position = matrix_projectionSkybox * (view * aPosition);
3942
vViewDir = aPosition.xyz * cubeMapRotationMatrix;
4043
44+
#ifdef SKY_FISHEYE
45+
// Bypass matrix_projectionSkybox which degenerates at extreme FOVs.
46+
// Use a fixed ~90° perspective (p00=p11=1) so the box always covers the
47+
// screen. The fragment shader recomputes view direction from screen
48+
// coordinates, so only rasterization coverage matters here.
49+
vec4 viewPos = view * aPosition;
50+
gl_Position = vec4(viewPos.xy, 0.0, -viewPos.z);
51+
vClipXYW = vec3(gl_Position.xy, gl_Position.w);
52+
#else
53+
gl_Position = matrix_projectionSkybox * (view * aPosition);
54+
#endif
55+
4156
#ifdef PREPASS_PASS
4257
// for infinite skybox, use negative gl_Position.w to get positive linear depth
4358
vLinearDepth = -gl_Position.w;

src/scene/shader-lib/wgsl/chunks/skybox/frag/skybox.js

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ export default /* wgsl */`
1414
varying vViewDir : vec3f;
1515
uniform skyboxHighlightMultiplier : f32;
1616
17+
#if defined(SKY_FISHEYE) && !defined(SKYMESH)
18+
uniform fisheye_k : f32;
19+
uniform fisheye_invK : f32;
20+
uniform fisheye_projMat00 : f32;
21+
uniform fisheye_projMat11 : f32;
22+
uniform matrix_view : mat4x4f;
23+
uniform cubeMapRotationMatrix : mat3x3f;
24+
varying vClipXYW : vec3f;
25+
#endif
26+
1727
#ifdef SKY_CUBEMAP
1828
1929
var texture_cubeMap : texture_cube<f32>;
@@ -52,22 +62,53 @@ export default /* wgsl */`
5262
var linear : vec3f;
5363
var dir : vec3f;
5464
55-
#ifdef SKY_CUBEMAP
65+
// --- compute view direction ---
66+
67+
#if defined(SKY_FISHEYE) && !defined(SKYMESH)
68+
69+
let ndc : vec2f = input.vClipXYW.xy / input.vClipXYW.z;
70+
let px : f32 = ndc.x / uniform.fisheye_projMat00;
71+
let py : f32 = ndc.y / uniform.fisheye_projMat11;
72+
let r : f32 = sqrt(px * px + py * py);
73+
let theta : f32 = uniform.fisheye_k * atan(r * uniform.fisheye_invK);
74+
let sinT : f32 = sin(theta);
75+
let cosT : f32 = cos(theta);
76+
var camDir : vec3f;
77+
if (r > 1e-6) {
78+
camDir = vec3f(px / r * sinT, py / r * sinT, -cosT);
79+
} else {
80+
camDir = vec3f(0.0, 0.0, -1.0);
81+
}
82+
let viewMat3 : mat3x3f = mat3x3f(
83+
uniform.matrix_view[0].xyz,
84+
uniform.matrix_view[1].xyz,
85+
uniform.matrix_view[2].xyz
86+
);
87+
dir = transpose(viewMat3) * camDir;
88+
dir = dir * uniform.cubeMapRotationMatrix;
89+
90+
#elif defined(SKY_CUBEMAP) && defined(SKYMESH)
91+
92+
// get vector from world space pos to tripod origin
93+
var envDir : vec3f = normalize(input.vWorldPos - uniform.projectedSkydomeCenter);
94+
dir = envDir * uniform.cubeMapRotationMatrix;
95+
96+
#else
97+
98+
dir = input.vViewDir;
5699
57-
#ifdef SKYMESH
58-
// get vector from world space pos to tripod origin
59-
var envDir : vec3f = normalize(input.vWorldPos - uniform.projectedSkydomeCenter);
60-
dir = envDir * uniform.cubeMapRotationMatrix;
61-
#else
62-
dir = input.vViewDir;
63-
#endif
100+
#endif
101+
102+
// --- sample environment ---
103+
104+
#ifdef SKY_CUBEMAP
64105
65106
dir.x *= -1.0;
66107
linear = {SKYBOX_DECODE_FNC}(textureSample(texture_cubeMap, texture_cubeMap_sampler, dir));
67108
68109
#else // env-atlas
69110
70-
dir = input.vViewDir * vec3f(-1.0, 1.0, 1.0);
111+
dir *= vec3f(-1.0, 1.0, 1.0);
71112
let uv : vec2f = toSphericalUv(normalize(dir));
72113
linear = {SKYBOX_DECODE_FNC}(textureSample(texture_envAtlas, texture_envAtlas_sampler, mapRoughnessUv(uv, uniform.mipLevel)));
73114

src/scene/shader-lib/wgsl/chunks/skybox/vert/skybox.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export default /* wgsl */`
88
99
varying vViewDir : vec3f;
1010
11+
#ifdef SKY_FISHEYE
12+
varying vClipXYW : vec3f;
13+
#endif
14+
1115
#ifdef PREPASS_PASS
1216
// when skydome renders depth during prepass, generate linear depth
1317
varying vLinearDepth: f32;
@@ -40,9 +44,20 @@ export default /* wgsl */`
4044
view[3][0] = 0.0;
4145
view[3][1] = 0.0;
4246
view[3][2] = 0.0;
43-
output.position = uniform.matrix_projectionSkybox * (view * input.aPosition);
4447
output.vViewDir = input.aPosition.xyz * uniform.cubeMapRotationMatrix;
4548
49+
#ifdef SKY_FISHEYE
50+
// Bypass matrix_projectionSkybox which degenerates at extreme FOVs.
51+
// Use a fixed ~90° perspective (p00=p11=1) so the box always covers the
52+
// screen. The fragment shader recomputes view direction from screen
53+
// coordinates, so only rasterization coverage matters here.
54+
var viewPos : vec4f = view * input.aPosition;
55+
output.position = vec4f(viewPos.xy, 0.0, -viewPos.z);
56+
output.vClipXYW = vec3f(output.position.xy, output.position.w);
57+
#else
58+
output.position = uniform.matrix_projectionSkybox * (view * input.aPosition);
59+
#endif
60+
4661
#ifdef PREPASS_PASS
4762
// for infinite skybox, use negative gl_Position.w to get positive linear depth
4863
output.vLinearDepth = -pcPosition.w;

0 commit comments

Comments
 (0)