A procedural sky (see cubemap_gen.py) drawn as a skybox, plus a teapot shaded with reflection, refraction and a Schlick-Fresnel mix sampled from the same cubemap. There are two versions of the same demo:
main.py:- OpenGL,GL_TEXTURE_CUBE_MAPuploaded withglTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, ...)per faceSkyBoxEnvMapWebGPU.py:- WebGPU, one texture with 6 array layers viewed withdimension="cube"and sampled astexture_cube<f32>
Both generate and upload the identical six-face cubemap from cubemap_gen.py, so the two demos render the same sky.
This can be used as a reference for implementing cubemap-based skybox rendering in your own projects and you can also modify to use image assets instead of procedural generation by loading your own cubemap texture into the arrays cube_map_data and cube_map_data_webgpu.
| Key | Action |
|---|---|
M |
cycle teapot shading mode: reflect / refract / Fresnel mix / plain diffuse |
+ / - |
increase / decrease the index of refraction (refract & Fresnel modes) |
| LMB / RMB / wheel | rotate / pan / zoom, Space resets, Esc quits |
- A skybox is a unit cube sampled with a direction, not a UV. The cube's own local position is the cubemap lookup direction, which is why no texture coordinates are needed for it.
- The view matrix loses its translation for the skybox. Stripping translation (
P * mat4(mat3(V))in GLSL, an equivalent CPU-built rotation-only matrix in WGSL) keeps the sky centred on the camera regardless of where it moves — it should feel infinitely far away. - The skybox is pushed to the far plane and drawn last.
gl_Position = pos.xyww(GLSL) / settingclip.z = clip.w(WGSL) forces every skybox fragment to the far depth plane; combined withGL_LEQUAL/less-equaldepth compare and depth write disabled, it only survives where nothing opaque has already been drawn there. Despite what most tutorials do, drawing opaque geometry first and the skybox last is the faster order on modern hardware — early-z rejects the (full-screen, expensive-ish) skybox fragments behind already-shaded pixels instead of the skybox blindly filling the screen before anything else exists to reject against. reflect()/refract()need world space, not view space. Because the cubemap is sampled in world space, the teapot shader carries the model matrix and a world-space normal matrix (Mat3.from_mat4(M).inverse().transposed(), not the usualMV-based one) plus the camera's world position, rather than the view-space lighting setup used elsewhere in these demos.- Fresnel mix is what makes glass/water look right. A pure reflect or refract teapot looks flat; blending them with a Schlick-Fresnel term (
F0 + (1-F0)(1-cosθ)^5) makes grazing angles reflect more and head-on angles refract more, which is what a real dielectric surface does.
The classic cubemap bug is getting face order or per-face orientation wrong, which shows up as a visible seam at a cube edge. cubemap_gen.py computes each face's colour from a proper per-texel 3D direction vector (not a naive "paint a gradient across the image" approach), and the tests check that the shared edge between the +x and +z faces produces bit-identical pixels from both faces — the same check is worth doing visually with a manual orbit in either demo.
There are some simple tests in SkyBoxEnvMap/tests that check the cubemap generation and rendering logic.
uv run pytest SkyBoxEnvMap/tests- LearnOpenGL — Cubemaps — skybox rendering, the
mat3(view)translation-stripping trick, and reflection/refraction cubemap sampling. - OpenGL Wiki — Cubemap Texture — the canonical face order and per-face
s,tmapping table. - B. Smits, "Efficiency Issues for Ray Tracing" (Schlick's approximation origin context) and Wikipedia — Schlick's approximation — the Fresnel term used in the mix mode.
- WebGPU spec — texture_cube sampling —
textureSamplewithtexture_cube<f32>.
