Product viewer walkthrough
The WebGL guide introduces ctx.glScene; this one walks through composing a small two-route product viewer from it — the same shape as the standalone examples/gl-viewer app, mirrored here as one scroll-scrub page and one plain-orbit companion.
See it live: scroll-scrub viewer · orbit view.
The shared scene declaration
Both routes declare the same mesh, environment, camera, light, and pick target — only the interaction mode differs at the end of the chain:
const scene = ctx.glScene(.{
.src = "/gl/demo.vmesh",
.env = "/gl/studio.venv",
.poster = POSTER, // inline SVG data URI
})
.camera(.{ .distance = 4, .pitch = 0.3, .yaw = 0.6 })
.light(.{ .dir = .{ -0.4, -0.7, -0.6 }, .intensity = 3.0 })
.onPick("Cube", 0)
.scrub(true) // or .autoRotate(0.2).scrub(false) for the orbit variant
.build();Nothing here is bespoke plumbing: ctx.glScene is the same fluent GlSceneBuilder used by every other declarative demo in this site (see Declarative 3D scene) — a product viewer is just a particular choice of options on the same builder.
Mesh + IBL environment
.src points at a .vmesh (geometry + baked textures, produced by tools/gl_asset_gen.zig from a glTF/GLB source) and .env at a .venv (a prefiltered image-based-lighting environment). Both are fetched by the chunk via gl_load into a page-scoped GPU asset region — nothing is inlined into the wasm binary. Rendering is Cook-Torrance PBR under that IBL environment plus the scene's direct light(s); see verve.gl for the full asset-format table.
Camera and light
.camera(.{ .distance, .pitch, .yaw }) seeds the damped orbit camera's starting pose; .light(.{ .dir, .intensity }) adds a single directional light (use .lights(&[]Light{...}) for up to four mixed directional/point/spot lights, or .spotLight/.pointLight to append one at a time — see verve.gl for the full Light struct). Both are encoded into the frozen positional Props the chunk decodes on hydrate.
Picking
.onPick("Cube", 0) registers "Cube" as a pickable mesh name with runtime closure id 0. On a hit the GlScene chunk stamps data-gl-pick on the canvas (client-side only — it never appears in the SSR HTML); a data-gl-hover attribute is stamped the same way while the pointer rests over a pickable mesh without dragging. Closure id 0 means "no server-side closure to invoke" — the attribute is the only signal, which is enough for demos that just want to show which mesh was hit. Up to four meshes share this budget with .onPickExport, which dispatches a bubbling CustomEvent instead of relying on a closure id — see verve.gl for both forms side by side.
The scroll-scrub turntable
.scrub(true) is what turns a static orbit scene into a turntable: the builder wraps its own canvas in a 300vh <section> with a sticky, full-viewport inner div — you don't build that scaffold yourself, .build() emits it inside the island so queryRef("glscene-scroll-section") can find it post-hydrate. Scroll progress through that section drives model yaw; the builder forces autoRotate to 0 in this mode automatically, since a continuous auto-spin would fight the scroll-driven rotation. Once the section scrolls past, the model is free for manual orbit (drag) and zoom (wheel) as usual. This is the same verve.anim scroll-scrub machinery used elsewhere in the animation system, here pointed at a GL uniform instead of a CSS transform — see Advanced WebGL.
The orbit variant
Drop .scrub(true) and add .autoRotate(0.2) instead: the scene idles at a gentle continuous spin (0.2 rad/s) until the visitor grabs it with a drag, exactly like every other non-scrub demo in this site. The important difference from the scroll-scrub route: in non-scrub mode .build() returns a plain wrapper and the page must supply a sized container — the builder has no scroll section to anchor a definite height to. Every existing orbit-style glScene demo in this codebase satisfies that by giving the page's .gl-scene-page wrapper a real layout; follow the same pattern rather than introducing a bespoke aspect-ratio box per page.
Poster and context-loss recovery
.poster takes an optional data URI (or any URL) shown as an <img
data-gl-poster> sibling of the canvas. It's visible by default — including with JavaScript disabled — and the bridge hides it the moment the chunk draws its first real frame. The same poster reappears automatically if the GPU context is lost (webglcontextlost on WebGL2, a lost GPUDevice on WebGPU) and stays up until rendering recovers, so a dropped context never leaves a blank canvas — it falls back to whatever the poster was showing before hydration took over.
Why two routes, one declaration
Splitting the scrub and orbit compositions into separate routes (rather than toggling a mode on one page) keeps each page's copy focused — "scroll to spin" versus "drag to orbit" are different affordances worth explaining separately — while the scene declaration itself stays a two-line diff between them. Cross-link the pair from each page (← Back to the
scroll-scrub viewer / Try the interactive orbit view →) so visitors can compare both interaction modes on the same mesh.
Next: Advanced WebGL.