Two small, focused demos sharing one folder because they cover the two GL pipeline stages that only exist on desktop GL/Vulkan, not WebGPU:
normals_main.py— a geometry-shader normal visualiser: draws a teapot twice, once shaded normally and once with a second program whose geometry shader turns every triangle into 1-3 short lines along its normal(s).tess_main.py— a tessellated, noise-displaced plane: a 16x16 grid ofGL_PATCHESsubdivided by a tessellation control/evaluation shader pair, with distance-based level-of-detail.
Both are run independently; RunDemos.py discovers them automatically as
two entry points in the same folder.
Vertex Shader
|
v
[ Tessellation Control Shader ] --\
| | (fixed-function tessellator
v | generates new vertices from
[ Tessellation Evaluation Shader ]-/ gl_TessLevelOuter/Inner)
|
v
[ Geometry Shader ]
|
v
Fragment Shader
normals_main.pyexercises Vertex -> Geometry -> Fragment (no tessellation stages).tess_main.pyexercises Vertex -> Tess Control -> Tess Evaluation -> Fragment (no geometry stage).- Neither demo uses all 5 stages in one program at once — that's deliberately avoidable complexity; each demo isolates the stage pair it is teaching.
WebGPU has no geometry shader stage and no tessellation stages at all.
This is not an oversight in ncca.ngl's WebGPU backend; the WebGPU spec
itself only defines vertex, fragment and compute stages. Where a WebGPU
pipeline needs "a geometry shader" it reaches for a compute shader that
writes a vertex/index buffer (or storage buffer) which a normal vertex
shader then reads, and "tessellation" becomes either a fixed subdivision
scheme baked into the mesh, or displacement done per-vertex in a compute
pass with a fixed input resolution. Both are strictly more code than the
one-line layout(...) in/out declarations GLSL gives you here — which is
itself the teaching point: these two GL-only stages are a convenience
the GPU vendors specifically decided not to standardise into WebGPU,
because they map awkwardly onto modern tile-based/mobile GPU architectures
compared to compute-based equivalents.
ShaderLib.load_shader() will attach a geometry shader for you (the geo=
parameter, used by normals_main.py) but has no tesc/tese parameters.
ShaderType does define TESSCONTROL/TESSEVAL though, so
tess_main.py builds its program from the lower-level per-stage API
(create_shader_program, attach_shader, compile_shader and friends) —
see load_tess_program() at the top of that file. The program ends up
registered in ShaderLib like any other, so ShaderLib.use(...) and
ShaderLib.set_uniform(...) work unmodified.
| Key | Action |
|---|---|
F |
toggle vertex-normal mode (smooth) vs. face-normal mode (faceted) |
+ / - |
increase / decrease the visualised normal length |
| LMB / RMB / wheel | rotate / pan / zoom, Space resets, Esc quits |
The geometry shader (shaders/NormalLinesGeometry.glsl) declares
layout(triangles) in; layout(line_strip, max_vertices = 6) out; — it
receives the vertex shader's per-vertex view-space position/normal for
all 3 triangle corners (vPosView[3], vNormalView[3]) and, per triangle:
- Vertex mode (
Foff): emits one 2-vertex line per input vertex, starting at that vertex and running along its own interpolated normal — this is what makes smooth (Gouraud-style) shading normals visible as they fan out across a curved surface like the teapot body. - Face mode (
Fon): averages the triangle's 3 positions and 3 normals down to a single centre point and face normal, and emits one line from there — this is the "faceted" convention: one flat normal per triangle, matching what flat shading would use.
The result is drawn as a second full pass over the same teapot geometry
(same VAO, same attribute locations 0/1) with ShaderLib.use() switched
to the line-drawing program — geometry shaders don't require rebuilding
any vertex data, only a different program bound at draw time.
| Key | Action |
|---|---|
L |
toggle distance-based LOD vs. a fixed tessellation level |
+ / - |
(fixed-level mode only) raise / lower the fixed level |
W |
toggle wireframe — this is the whole point of the demo |
| LMB / RMB / wheel | rotate / pan / zoom, Space resets, Esc quits |
Pipeline, in order:
- Vertex shader (
TessPlaneVertex.glsl) — passes each of the grid's 4-vertex-per-patch control points through the model matrixMinto world space. No other work happens here; tessellation reads/writes happen downstream. - Tessellation control shader (
TessPlaneControl.glsl,layout(vertices = 4) out) — runs once per output control point (4, matchingglPatchParameteri(GL_PATCH_VERTICES, 4)), and — only on invocation 0 (gl_InvocationID == 0, sincegl_TessLevelOuter/Innerare per-patch state, not per-invocation) — sets the 4 outer and 2 inner tessellation levels from camera distance, clamped to[1, 64].Lswaps this for a single fixed level driven by+/-. - Fixed-function tessellator — not a shader stage at all: consumes
gl_TessLevelOuter/Innerand thequads/fractional_even_spacingdeclaration from the TES below, and generates new vertices with barycentric-stylegl_TessCoordparametric coordinates inside the patch. - Tessellation evaluation shader (
TessPlaneEval.glsl,layout(quads, fractional_even_spacing, ccw) in) — runs once per generated vertex: bilinearly interpolates the patch's 4 world-space corners usinggl_TessCoord.xy, displacesyby a 4-octavefbm()noise function (hand-written in GLSL — hash -> value noise -> fbm, no texture lookups), and derives the surface normal from finite differences of that same noise field (sample the height a smallepsstep away inx/z, cross the two resulting tangents). - Fragment shader (
TessPlaneFragment.glsl) — shades by a height-driven colour ramp modulated byN.Lagainst a fixed world-space light direction.
Spacing modes (declared on the TES's layout(quads, ...)):
fractional_even_spacing was chosen over equal_spacing because
equal_spacing only ever produces integer numbers of segments per
edge — as the computed LOD crosses each integer boundary, a whole new
row of triangles snaps into existence, which is highly visible "popping".
fractional_even_spacing (and fractional_odd_spacing, its odd-count
sibling) instead grow/shrink the outermost ring of triangles' edge
lengths continuously between integer levels, so the only visible change
across an LOD boundary is that outer ring's shape — no triangles
appear/disappear abruptly. Toggle L to fixed levels and step +/- to
see this directly: each fixed integer level is a "clean" tessellation, but
sweeping the LOD continuously with distance (L off) is what
fractional_even_spacing is actually for.
- Patches draw nothing, silently, without
glPatchParameteri.gl.glPatchParameteri(gl.GL_PATCH_VERTICES, 4)must be called before drawingGL_PATCHES— there is no GL error if you forget it, the draw call just produces zero visible geometry. - TCS must only write levels on invocation 0.
gl_TessLevelOuter/Innerare per-patch, not per-invocation; every invocation writing them is a race. Guarded withif (gl_InvocationID == 0). - There is no
Primitivespath for patches. The control-point grid is built by hand in numpy (tess_grid.build_patch_grid, tested headlessly intests/test_tess_grid.py) and uploaded as a flat, non-indexedVAOFactory.create_vao(VAOType.SIMPLE, gl.GL_PATCHES)buffer — every consecutive group of 4 vertices is one patch.
tess_grid.py is a numpy-only module (no GL/Qt/OpenGL imports) holding:
build_patch_grid(resolution, size)— the control-point layout, tested for vertex count, centring, flat (y=0) start state, and correct counter-clockwise quad winding per patch.tess_level_from_distance(...)— the same near/far distance ->[1,64]clamp curve the TCS re-implements in GLSL, tested for its clamping and monotonicity behaviour independent of any GL context.
uv run pytest GeometryTessellation/tests- Khronos, OpenGL Wiki — Tessellation — TCS/TES stage overview,
gl_TessLevelOuter/Inner, and the spacing modes. - Khronos, OpenGL Wiki — Geometry Shader — input/output primitive types and
EmitVertex/EndPrimitive. - P. Cozzi & C. Riccio (eds.), OpenGL Insights, "Chapter 9: Malformed Surfaces" and the tessellation chapters — practical GPU tessellation patterns.
- I. Quilez, "fbm" / value-noise notes — the hash -> value-noise -> fbm construction used in
TessPlaneEval.glsl. - W3C WebGPU specification — defines only vertex, fragment and compute pipeline stages, confirming the WebGPU-absence note above.
