Advanced WebGL
The WebGL guide introduces ctx.glScene. This one goes deeper into the rendering pipeline — materials, lighting, picking, and the per-submesh shader-variant selection added in verve.gl P9.
See it live: viewer · scene · mixed materials.
How a scene reaches the GPU
Zig owns the scene; JS is a dumb WebGL2 interpreter. The chunk builds a binary draw-command stream in shared linear memory — SET_PIPELINE, SET_LIGHTS, BIND_IBL, draw calls — and the bridge replays it on a WebGL2 context. Nothing about the scene lives in JavaScript; swap the stream and the picture changes. Rendering is Cook-Torrance PBR under image-based lighting plus direct lights, with a software rasterizer fallback used server-side for tests.
Assets: glTF → vmesh, HDR → venv
glTF --tools/gl_asset_gen.zig--> .vmesh (geometry + baked textures)
HDR --tools/gen_demo_hdr.zig--> .venv (prefiltered IBL environment)The chunk fetches both via gl_load into a page-scoped GPU asset region (verve_asset_reset clears it between client-side navigations). Build with -Dgl-ibl-fast to lower IBL prefilter sample counts for a markedly faster build at coarser lighting quality — the .venv format is unchanged, so it's purely a build-time/quality trade.
Correctness baked at parse time (P8): the glTF reader composes node TRS/matrix transforms down the hierarchy and bakes them into geometry (position, inverse-transpose normals, tangents) — previously node transforms were silently ignored. Texture indices are validated at parse (error.BadTexIndex for out-of-range), and base/emissive textures are flagged sRGB so the shader samples them in the right color space without an in-shader pow(2.2).
Per-submesh shader variants (P9)
A mesh's submeshes rarely all need the same shader. P9 picks the leanest correct variant per submesh instead of running one über-shader over everything. vmesh.Reader.submeshVariant(s) derives a variant bitset (variant_pbr | normal_map? | emissive?) from the submesh's texture indices — reusing the -1 "missing map" sentinel, so the vmesh format is unchanged.
GlScene then:
- creates one shader per distinct variant actually used (deduped, and each recorded for context-restore replay),
- emits one
SET_PIPELINEper variant group, - re-emits
SET_LIGHTS/BIND_IBLafter each pipeline switch, per the wire stream-order rule.
The mixed-materials example shows it directly: a full-PBR cube (base · metallic-roughness · normal · emissive) beside a base-color-only cube. The base-only submesh never pays for normal or emissive sampling — same draw stream, two pipelines.
Shadows and culling (P9)
Two more P9 pieces, both automatic — no builder opts:
- Directional shadow map. The single directional light gets a depth pass: the scene is rendered from the light's point of view into a depth texture, then receivers sample it with 3×3 PCF for soft edges. The shadow example is a cube casting onto a floor — position the camera to look slightly down so the cast shadow is in frame.
- Per-node frustum culling. Nodes fully outside the camera frustum are dropped before the draw-command stream is built (
src/core/gl/cull.zig), so off-screen geometry costs nothing on the wire or the GPU.
Shadow variants (v0.6.0+)
Each light kind has a dedicated shadow variant, selected automatically:
- Spot shadow — perspective depth pass with fov = 2 ×
outer_deg, aspect 1:1, near 0.1, far =range. 3×3 PCF. Setcasts_shadow = trueon a spot light and call.spotLight()or include it in.lights(). See gl-spot. - Point omnidirectional shadow — 6-face 2D distance atlas (1536×4096, arranged 3×8 with 512² tiles — 6 faces × up to 4 casters). Each face stores linear distance in an RGBA8 texture. The chunk selects the correct face at runtime and applies 3×3 PCF. Enabled by
casts_shadow = trueon a point light. See gl-point. - Multi-shadow — when multiple lights in a
.lights()call each havecasts_shadow = true, the engine allocates one shadow map per caster and composites them. See gl-multishadow. - Cascaded shadow maps (CSM) — splits the view frustum into cascades for directional shadows at large scene scales; each cascade covers a depth range with its own depth pass. See gl-csm.
Shadow-pass frustum culling applies: off-screen geometry is skipped in depth passes as well as the main pass.
Fog mechanics
.fog(FogOpts) is applied in the fragment shader after PBR shading, before ACES tonemapping. Distance is computed as radial (camera-to-fragment eye space). Four modes:
| Mode | Effect |
|---|---|
.none | No fog (default) |
.linear | mix(color, fog_color, clamp((d - near) / (far - near), 0, 1)) |
.exp | exp(-density * d) blend factor |
.exp2 | exp(-(density * d)²) — faster falloff |
The fog color should match the background/environment tone for a seamless blend. Transport: data-glfog="mode,r,g,b,near,far,density" (7 scalars, emitted by .build() when mode ≠ .none).
Morph targets
Morph targets (blend shapes) deliver POSITION + NORMAL deltas via a 2D texture atlas (not extra vertex attributes). Up to 8 active influences can be weighted simultaneously. The vmesh bakes the deltas at build time; .morphWeights([]const f32) sets the initial weights and writes them to data-glmorph. At runtime:
morph:<i>anim target — animate weightiviaverve.animtween/scrub (see Advanced Animation).z-on-clickexports —glmorph_bulge_on/glmorph_resetare pre-wired closure ids for click-to-toggle without custom Zig code.
See gl-morph for a live blend-shape demo.
GPU instancing
GPU instancing uses EXT_mesh_gpu_instancing: all instance transforms are uploaded in a single vertex-buffer (stride 80 bytes: 4×vec4 TRS + padding), and the engine emits one draw_pbr_instanced command covering all instances. The variant variant_instanced selects the instanced shader path.
This is distinct from the P7 multi-instance chunk constraint (multiple GlScene islands on one page, each with its own state slot). GPU instancing is about one island drawing hundreds of copies of a mesh; P7 multi-instance is about multiple islands coexisting on the page.
See the gl-instanced demo (16×16 = 256 cubes in one draw call).
Area lights (LTC)
Linearly Transformed Cosines (LTC) approximate GGX BRDF integration over a rectangular luminaire analytically — no sampling. The LUT data is fetched from /gl/ltc.bin the first time an area light is active. Key geometry:
pos— light centerex,ey— half-edge vectors; rect corners arepos ± ex ± ey- Rect normal:
normalize(cross(ex, ey))
When casts_shadow = true, a perspective depth pass is rendered from pos along the rect normal (spot-like). two_sided = true is reserved; the LTC evaluation is single-sided by default.
See gl-area.
G-buffer depth+normal prepass (v0.12.0)
v0.12.0 added an internal depth+normal prepass that lays the foundation for future SSAO, SSR, and contact shadows. It has no public builder surface — no API to configure or disable it. The prepass runs automatically and is transparent to existing scenes.
Picking
Two ways to react to a click on a named mesh:
ctx.glScene(.{ .src = "/gl/model.vmesh", .env = "/gl/studio.venv" })
.onPick("Cube", 0) // runtime closure id (max 4 meshes)
.onPickExport("Lid", "verve:glpick") // P8: bubbling DOM CustomEvent
.build();.onPick fires a runtime closure id; .onPickExport dispatches a bubbling CustomEvent(event_name, { detail: { name } }) from the canvas, so plain DOM listeners (or other islands) can react without a closure. Both share the 4-pick budget and may target the same mesh.
Scroll-scrub turntable
.scrub(true) fuses gl with verve.anim: the builder wraps the canvas in a 300vh sticky section and drives yaw from scroll progress, zeroing autoRotate (scroll owns rotation). It's the same ScrollTrigger machinery the rest of the animation system uses, pointed at a GL uniform instead of a CSS transform.
Instancing and the chunk constraint
A GL chunk is stateful, but P7 multi-instance lifts the old one-per-page limit: several distinct GlScene islands can share a page — each <verve-island> carries its own per-instance state keyed by vid, and the bridge selects the right instance before each frame and event. (One island driving multiple canvases still merges into a single chunk, as gl-viewer's GlDemo does — that's a different axis.) The same vid routing delivers pushed SSE frames to the subscribing instance — see multi-instance push.
The props codec is positional — src env orbit_distance orbit_pitch
orbit_yaw auto_rotate light_dir_x/y/z light_intensity pick_names[]
pick_event_ids[] scrub pick_export_names[] — and that order must match across islands.zig, core/gl_scene.zig, and the chunk, or hydration decodes garbage.
Next: Realtime & SSE.