Skip to content

Realtime renderer

github-actions[bot] edited this page Nov 9, 2026 · 16 revisions

Realtime renderer

@modelshot/renderer-rt draws a SceneDocument with WebGL2: crisp pixel art, per-texel transparency and the AgX view transform. It draws only when render() is called; needsRender tells the caller's animation loop whether a scene, camera or size change is waiting, or a still exact-key frame has not drawn all its samples yet (Passes): render until it is false before reading the canvas (the app's Viewer.renderNow does, through renderSettled).

const renderer = RealtimeRenderer.create(canvas, { look: LOOK_PRESETS['marketplace'] });
const controller = new OrbitController();
const { warnings } = await renderer.setScene(doc);
controller.setAspect(canvas.width / canvas.height);
controller.reset(sceneBounds(doc));
renderer.setCamera(controller.camera());
renderer.render();

observeCanvasSize(canvas, (size) => renderer.resize(size)) keeps the backing store at CSS size × devicePixelRatio (clamped to MAX_VIEWPORT_DIMS, MAX_RENDERBUFFER_SIZE and the maxPixels option, aspect kept, never 0) through element resizes, browser zoom and moves between screens. bindOrbitInput(canvas, controller, { onChange }) wires mouse, touch and keys (see camera.md).

Passes

Float path (needs EXT_color_buffer_float):

  1. With baked lighting that carries one, the shadow catcher (below) right after the clear, without depth test or write.
  2. Opaque pass into an RGBA16F + DEPTH24 target with MSAA (min(4, the largest sample count the GPU offers for RGBA16F); 0 samples draws single-sampled), resolved by a blit of colour and depth. With texel anti-aliasing on and at least 2 samples, raster frames draw it with SAMPLE_ALPHA_TO_COVERAGE (alpha written off), so cut-out seams are anti-aliased per sample (Alpha, below).
  3. Weighted blended order-independent transparency (McGuire and Bavoil 2013, weight of equation 10) for partial texels: an RGBA16F target (rgb: weighted premultiplied colour, a: product of 1 − alpha) and an R16F target (sum of weighted alpha), depth-tested against the resolved depth with depth writes off. Only batches whose texture has partial alpha are drawn here (one readback per texture at load decides it). Raster frames with at least 2 MSAA samples draw it into multisampled RGBA16F and R16F renderbuffers at the opaque pass's sample count, tested per sample against the opaque pass's MSAA depth, and resolve each into its texture by its own blit (oitMsaaActive: within the R16F sample limit and 2^23 samples x pixels, about 80 MB, so 4 samples up to 1920 x 1080; allocated on the first frame that needs them, an incomplete target is not tried again at that size). The composite then reads the per-pixel mean of every sample's sums, so the outline of a face with only partial texels is anti-aliased like an opaque one; above the cap, without MSAA and in exact-key frames the pass stays single-sampled and those outlines hard.
  4. Composite of the transparency average over the opaque image.
  5. Present: AgX and the display encoding into the canvas.

Exact-key frames (Transparency, sides and render order, below) cannot use MSAA: their key is the depth at a pixel centre. On the float path a still one is antialiased over several render() calls instead (progressive rotated-grid accumulation, ExactSampleHistory): each call draws passes 0-3 once, single-sampled as before, with the projection shifted by the next of 4 sub-pixel offsets (EXACT_SAMPLE_OFFSETS, pixels: (−1/8, −3/8), (1/8, 3/8), (3/8, −1/8), (−3/8, 1/8), a rotated grid, so a horizontal or vertical edge crosses 4 sample rows) in u_viewProjection and the key's u_inverseProjection (the look, the rim and the depth pull are unshifted), copies the result into that slot of a 4-layer RGBA16F TEXTURE_2D_ARRAY (a blit), and presents the mean of the filled slots in linear HDR before AgX, as the MSAA resolve does (presentSamples.frag.glsl, summed in a fixed order). Every sample has its own centre ray and key, so no hidden face can win one, and silhouettes and cut-out seams settle to 4 samples per pixel after 4 calls; a frame in motion shows one sample, drawn at the first slot's offset (−1/8, −3/8) px, not at the pixel centre. needsRender stays true until the 4 slots hold the current view; anything that sets it (camera, scene or pose, look, edges, setLighting itself, the completion of its upload, resize) starts the history again, as do a raster frame, a context restore and a switch to the no-float path. setCamera with a camera of the same fields (compared field by field; one that cannot be framed just counts as changed) changes nothing, so a caller that sets the camera every tick still converges. Once full, further calls keep drawing one pass each into the next slot (the same image): every call costs one pass, never four. exactSampleCount allows it only when MSAA would have run (at least 2 samples), a slot fits MAX_TEXTURE_SIZE, and caps it at 2²³ pixels × slots (EXACT_SUPERSAMPLE_MAX_PIXELS: 1920 × 1080 fits; 8 bytes per pixel and slot, 32 per pixel, at most 64 MB); otherwise the frame draws single-sampled at the pixel centre, as before. The history is allocated on the first exact frame, kept across raster frames, reallocated (empty) on a size change, and freed on dispose, on a switch to the no-float path and when the size no longer takes one; a size the driver rejects draws single-sampled and is not tried again until the size changes (never a switch to the no-float path for it). After a context restore it is allocated again on the next exact frame. Raster frames keep the single-texel present (present.frag.glsl), so their bytes are unchanged; the no-float path stays single-sampled.

No-float path (capabilities.path === 'fallback'): the backdrop, the shadow catcher (tone-mapped in its shader), then one pass into the canvas with AgX in the fragment shader, partial texels as hashed (screen-door) alpha from an integer hash of texel and pixel. Both paths are order-independent and deterministic (no clock, no random source). The renderer also switches to this path for good when the driver rejects the float targets at a new drawing size; capabilities then reports path: 'fallback' and 0 samples.

Geometry is batched at upload: the visible triangles of one node with one texture, alpha rule, sidedness and render order share one vertex and index buffer (batchScene). Every vertex keeps its own UV rectangle, so batching changes nothing per texel; a model with one mesh per element (thousands for a large Blockbench model) draws in a few calls per pass.

Baked lighting

setLighting(lighting) shades the scene with light baked off the main thread by LightBaker of @modelshot/pathtracer (path-tracer.md) and draws its shadow catcher; setLighting(null) goes back to the unbaked image, byte for byte. Without lighting nothing changes. Light is baked under a look (BakeOptions.look) and carries its lightingKey: setLighting refuses light of another key than the current look's, and setLook to a look of another key drops the shown light (look-presets.md).

const baker = LightBaker.create();
await renderer.setScene(doc);
const { lighting } = await baker.bake(doc, { onProgress: (p) => renderer.setLighting(p.lighting) });
if (lighting) renderer.setLighting(lighting);
  • Layout. setScene lays out a lightmap (lightmapLayout, pure and pose-independent) before anything else: every face whose UVs map one texture rectangle affinely (every Blockbench element) gets a chart at its own texel grid (faceTexelRange of its UV rectangle), so light changes per texel like the albedo, rotated or mirrored UVs included. Faces sharing atlas cells each get their own chart; a double-sided face gets a second chart for its back, right after the front one. A mesh with a vertex shared by triangles of different UV maps (smooth glTF and OBJ meshes), an untextured face (a glTF base colour swatch included), a degenerate UV triangle or a chart of more than MAX_CHART_TRIANGLES (32) triangles gets world charts instead (MS-41), when it has at most MAX_WORLD_MESH_TRIANGLES (4096) triangles: edge-connected coplanar triangles of one group (edges matched by position, so faces with their own vertices join; only an edge of exactly two triangles joins, so a fan of thousands on one edge costs linear time), at most 32 per chart, projected onto their plane along the triangle edge that bounds them tightest, at one world texel for the whole scene: the diagonal of the bounds of the visible meshes' kept triangles at rest (a vertex no triangle uses does not count) / WORLD_TEXELS_PER_EXTENT (256), never finer than the finest texture texel of a texel chart. A two-triangle ground of a glTF or OBJ scene so gets a shadow where the box on it stands, not one light value per corner. A denser mesh is baked per vertex: one texel per vertex (two when double-sided), interpolated. The atlas is LIGHTMAP_WIDTH (2048) wide; when the charts would need more than MAX_LIGHTMAP_TEXELS (2²¹) texels, the world texel doubles (up to 256 times) first, so world light never costs a textured face its resolution; then the world-charted meshes fall back to per-vertex light; then texel charts take 2, 4, … texture texels per lightmap texel, and when even that fails setScene rejects with RendererError SCENE_TOO_LARGE (path meshes) before allocating anything. A scene without a world-charted mesh lays out exactly as before world charts existed (same layoutKey).
  • Values. RGBA16F (BakedLighting): rgb 1 less the fill lost over the unoccluded fill hemisphereFill(n) (MS-87: the path tracer's control variate; a blocked bake ray loses the environment along it, so a texel nothing blocked is exactly 1; a cosine ray under a smooth face's geometric side is mirrored as the trace does, so such a texel is near but not exactly 1 and a mirror that sees more sky may lift it slightly above 1), a the key's visibility. Texels of no chart and vertices of no triangle hold 1, so an anti-aliasing tap outside a face, or a degenerate triangle, never darkens. The shadow catcher's lightmap stores the same form over hemisphereFill(up) (Shadow catcher, below). shadeBaked (shading.glsl) is albedo × (hemisphereFill(n) × rgb + u_lookKeyColor × max(n·u_lookKeyDirection, 0) × a + fillLight(n)) + rimLight(n, v, albedo), v toward the eye (towardEye(v_worldPosition)): the accents cast no shadow and stay whole; value 1 gives the unbaked expression exactly.
  • Sampling. Texel charts are read with the albedo's own seam taps (seamTaps of texelSample.glsl): the same 2 × 2 texels and weights, clamped to the chart, so light and albedo change at the same pixels and the bake adds no shimmer of its own. Both blends (texel taps, world charts) are lerpExact (lerpExact.glsl, a + (b − a) × f, not mix, which may round), the chunk the catcher shader includes too: four taps of rgb 1 give exactly 1, so a texel- or world-charted face whose fill lost nothing shades as hemisphereFill(n) exactly. Vertex charts (Gouraud interpolation of the corners) are not held to that. World charts are read bilinearly at their own grid (an untextured face has no texel grid to keep; seam taps would show its blocks), the 2 × 2 texels clamped to the chart: a world-charted mesh is drawn de-indexed, a vertex per triangle corner (LightmapLayout.cornerData, composed with the sharp-edge split, so edge rounding stays), each corner carrying its point in the lightmap and its chart's rectangle (attribute 11, uploaded only for a batch with world charts and left disabled elsewhere, as no other vertex reads it); the vertex shader passes them in the vertex-light varyings, as more varyings would exceed the minimum of 15: the exact depth key's pass uses all 15. Vertex charts are fetched in mesh.vert.glsl and interpolated; the back pass reads the back chart. The light is sampled before the first discard (derivatives).
  • Kept across poses. setScene keeps the lighting when the new document has the same layoutKey (the same model in another pose), so the old light shows until the re-bake's first update; another layout drops it. A document that differs from the shown one only in its pose (poseOnlyChange: the same meshes, materials and textures arrays, nodes with the same parents, pivots, rest rotations, visibility and meshes) keeps the layout without laying it out again; the renderer keeps only what it reads of a layout (LightmapLayoutSummary: sizes, downscale, per-vertex and per-corner data, finest texel, key), not its charts. World charts come from rest positions, so a pose keeps them. setLighting refuses (returns false, changes nothing) lighting whose layout key, size or data length does not match the current layout, whose lightingKey is not the current look's, or whose catcher is malformed (checkLighting).
  • Upload in bands. setLighting uploads nothing itself: each render() (or advanceLighting(), which uploads without drawing and returns true while more remains) writes the next UPLOAD_FRAME_BYTES (1 MiB) in texSubImage2D bands of at most UPLOAD_BAND_BYTES (256 KiB) into back textures, and frames draw the previous complete lighting until the new one is in (lightingPending), then switch; the old textures are kept for the next update of the same shape. One 9.4 MB texSubImage2D (the benchmark scene) makes the browser wait for its GPU process to take the data chunk by chunk, and that process also runs the bake's draws: a busy machine turned that into a 120 ms main-thread task (MS-16 round 4). A rebuilt context uploads its retained lighting at once.

Shadow catcher. A horizontal plane at the visible geometry's lowest point (shadowCatcherPlane, shared with the path tracer), covering the footprint, the bounds' shadow along the key and half the largest extent around it, one-sided from above. It only darkens the backdrop: pixel = the look's backdrop at the pixel × catcherShadeOfLoss(hemisphereFill(up) × (1 − rgb), 1 − a, xz, rect) of the look (shadowCatcher.glsl), with its own lightmap of 4 texels per finest model texel (at most MAX_CATCHER_SIDE 512 per side), read bilinearly with the shared lerpExact (a + (b − a) × f, not mix, which may round). Its rgb is 1 less the fill its rays lost over the sky fill (MS-63, BakedCatcher), the path tracer's control variate: where no texel of the four was blocked the loss is exactly 0 and the pixel is the bare backdrop, byte for byte, as in the render; catcherShade of the same means gives the same value up to rounding. Every model point a camera ray meets lies on or above the plane, so drawing the catcher first without depth test lets the model cover it in either depth-key variant. A scene without geometry has no catcher.

Pixel art

  • Textures are uploaded as SRGB8_ALPHA8 with NEAREST filtering and no mipmaps (mipmaps would average across atlas regions), without flipping or premultiplying; texel (0, 0) is the top-left pixel.
  • Texel anti-aliasing: four texelFetch taps (linear colour) blended only within one screen pixel of a texel seam (offset clamp((p − seam) / fwidth(p), −0.5, 0.5)), with premultiplied weights so a cut-out neighbour adds no dark fringe. Every tap is addressed by faceTexel (the faceTexels chunk of @modelshot/looks, which the path tracer addresses with too): clamped to the face's texel rectangle from SceneMesh.uvRects (faceTexelRange), then, where the face's UVs pass the image, repeated into it for a texture with wrap: 'repeat' or clamped to its edge texel otherwise (SceneTexture.wrap); on a strip the image is its frame 0. So a nearest or coverage tap reads only inside the face, with any face UV rotation or mirroring, and at a clamped edge the seam taps blend the edge texel with itself. A linear texture's colour taps (linearTexelRange) may read one texel past the face rectangle of the same image, as hardware linear filtering does, never another image or frame. The texture's flags (textureSamplingFlags: bit TEXTURE_FLAG_REPEAT, bit TEXTURE_FLAG_LINEAR, the frame count − 1 from bit 8) come per vertex (attribute 12, the same on every vertex of a batch, 4 bytes each) into a flat varying; the closed-opaque check on the CPU (translucentTexelsIn) counts the same repeated texels.
  • Minified (more than one texel per pixel): the same addressed lookup at a rotated 2 × 2 grid inside the pixel, which keeps far views from shimmering.
  • texelAntialias: false samples plain nearest texels (used by the shimmer test as its reference).
  • Linear textures (MS-38, SceneTexture.filter 'linear', from a glTF sampler): the colour is the bilinear blend of the four texels whose centres surround the point (linearTaps and bilinearColor of the texelFilter chunk of @modelshot/looks, which the path tracer blends with too), each tap addressed by faceTexel within the face's range widened by one texel (linearTexelRange: no seam on a triangle edge two faces share), every tap weighted by its coverage (coverageWeighted of texelAlpha), so a cut-out texel's colour adds no fringe. Only the colour changes: coverage, the pass a fragment goes to, the alpha the seam taps give an opaque|cut-out edge and the closed-opaque check all stay the nearest texel's, as for a pixel-art texture. Minified, the colour is the mean of the blend at the same rotated 2 × 2 grid. The filter is the texture's, so texelAntialias: false keeps it. Four more texelFetch taps per fragment (16 minified), only for such textures.

Transparency, sides and render order

The rules are the scene format's (scene-format.md: SceneMaterial for alpha, SceneMesh for doubleSided and renderOrder); this section only says how the renderer realises them.

  • Alpha: each draw gets its material's mode and cutoff as an alpha mode id and a cutoff byte (alphaCutoffByte, the smallest byte k with k / 255 ≥ cutoff), and #include "texelAlpha" (@modelshot/looks) classifies the nearest texel's alpha byte, which equals the scene's texelCoverage for every byte, mode and cutoff. Invisible texels are discarded in every pass before any depth write, opaque ones are drawn by the opaque pass, partial ones by OIT on the float path and hashed alpha on the fallback. Texel anti-aliasing weighs each tap by its coverage (invisible 0, opaque 1, partial its alpha), so an opaque-mode material ignores alpha and a cut-out neighbour adds no fringe. A texture goes to the transparency pass only if one of its alpha bytes is partial under the draw's rule (alpha bytes read back once at load).
  • Anti-aliased cut-out seams (MS-30): the one exception to the discard above is the opaque pass of a float-path raster frame into the MSAA target with texel anti-aliasing on (alphaToCoverageActive). There the same seam taps that blend the colour also blend a coverage indicator (opaque tap 1, invisible 0, a partial tap 1 beside an opaque nearest texel, so an opaque|partial seam stays as it was and OIT keeps the partial side), and the fragment writes it as alpha under SAMPLE_ALPHA_TO_COVERAGE. Across an opaque|cut-out seam it ramps over one pixel and is 0.5 exactly on the texel boundary, so the covered area equals the hard per-texel shape while edge pixels resolve to a mix of texel and background; fragments of a cut-out texel in that half-pixel band are kept with the opaque neighbour's colour and write depth only at their covered samples. Which texels are cut out does not change: texel centres show the background exactly. Exact-key frames (each pass single-sampled: the mean of a still frame's shifted passes antialiases their seams, Passes), 0 or 1 MSAA samples, texelAntialias: false, the fallback, OIT, the shadow catcher and the light bake (its own pass in @modelshot/pathtracer) keep the binary per-texel cutout; without sample buffers GL ignores A2C, which would grow the shape by half a pixel, so a uniform tells the shader. GLSL ES 3.00 has no gl_SampleMask, so A2C is the only per-sample route in WebGL2. Measured on SwiftShader (4 samples, 5 coverage levels per pixel); real GPUs may dither A2C per 2 x 2 pixel quad, and that check is manual. Under A2C an opaque nearest texel writes the taps' alpha-weighted coverage (premultiplied.a) instead: beside cut-out texels alone the same value bit for bit, beside a partial texel its share times that texel's alpha, so the samples it covers carry the opaque and partial shares and the uncovered ones what lies behind (MS-60).
  • Anti-aliased partial seams (MS-60): the OIT pass weighs a fragment by an anti-aliased coverage instead of its nearest texel's alpha (u_seamCoverage, oitSeamCoverageMode): the partial taps' share of the seam taps (premultiplied.a − opaqueCoverage, with no opaque nearest texel), so a partial|invisible seam ramps from the texel's alpha a to 0 over one pixel, a/2 on the texel boundary, and a partial|partial seam ramps linearly; the covered area is the hard per-texel shape's. Fragments of a cut-out texel within that band are kept with the partial neighbour's colour (premultiplied taps: no dark fringe); inside a texel the alpha is its own bit for bit, and which texels are visible does not change. Opaque taps count 0 beside a cut-out nearest texel (the opaque pass's band). Beside a partial nearest texel they count as its alpha in mode 1 (raster frames without MSAA, exact frames drawn once at the pixel centre: the opaque pass keeps its hard side, so the opaque|partial seam stays as before) and 1 in mode 2 (the opaque pass under A2C covers the opaque side, above). Mode 0, the nearest texel's rule, is kept for the passes of an accumulated exact frame (their mean antialiases the seams already; a ramp in each would filter twice) and for texelAntialias: false. The fallback, shadows, the AO and the light bake do not change. A linear texture (above) ramps the same: its filter rewrites only the colour after both coverages are set, and a band fragment takes the bilinear blend of the covered taps, so no dark fringe either (partial-edge-linear matches partial-edge within 1 level). Accepted residual on opaque|partial seams: A2C quantises their opaque side to the 4 samples' levels, and an opaque texel beside an alpha-128 one only spans coverage 0.75-1, so one level is half its ramp; WebGL2 has no gl_SampleMask to refine it. On SwiftShader the partial-edge strip covers 26.5 px more than the hard shape over 280 seam crossings, 0.095 px per crossing; the limit is 0.15 px per crossing, and the opaque side must mix (docs/testing.md). Partial|invisible seams and outlines meet 0.5 px per boundary row.
  • Sides: single-sided groups are drawn with back faces culled in every pass. Double-sided groups are drawn twice per render order: front faces, then back faces (front faces culled), the back lit with the flipped normal.
  • Render order: draws are sorted by render order, descending; per order the front pass, then the back pass of double-sided items. The scene format's key is realised in one of two depth-key variants, picked per frame by depthKeyMode (both linked at start-up; RealtimeRenderer.depthKeyMode reports the last frame's):
    • raster, used only where every visible mesh has render order 0: the depth buffer compares the projected depth; early depth testing and per-sample MSAA depth stay. The back pass gets polygonOffset(4, 4), a slope-scaled push that keeps a back side behind the front face it meets at a silhouette or coincides with (the −1/16 order step is far below the rasteriser's depth interpolation error; with (1, 1) back sides leak at MSAA samples next to a silhouette, see the closed-cube-cutout test). The back pass is skipped for a batch whose meshes are all closed solids wound outward (over the mesh's drawn triangles: every edge, matched by position, used exactly once in each direction; every edge-connected shell enclosing a positive signed volume, Σ dot(v0, v1 × v2) / 6 above 1e-6 of its bounding cube, so inside-out meshes, coincident opposite pairs and cavities fail; no shell's bounds inside another's; at most 1024 shells) and opaque on every face (untextured, the opaque alpha mode, or no alpha byte below 255 inside any face's UV rectangle, from a summed-area table of the load-time alpha readback; a failed readback counts as not opaque), unless the camera is inside the batch's world bounding box or the near plane reaches it: from outside, every ray meets a front face of such a mesh before any back side, so its back sides could only show as an error. Closed and opaque are decided per mesh at load (closedOpaqueMeshes; the solid check is about 90–115 ms in Node for one closed mesh at the loader's 400 000 vertices) and are part of the batch key. Deviation of this variant: a back side that really lies in front of a front face by less than about four pixels' worth of its own depth slope (e.g. about 0.3–0.5 units for a face at 45° in the turntable framing) is drawn behind it; and where the back pass is still drawn (a closed mesh with a cut-out or a translucent texel on a face, an open double-sided sheet, an inside-out mesh, the camera inside the bounds), next to the silhouette of a face within a few degrees of edge-on, MSAA samples of a hidden back side can still show: the depth stored at an edge sample is the front face's depth about half a pixel away, an error that scales with the front face's depth gradient, so no back-face offset removes it (closed cube with cut-outs at 1°: 10 pixels, pinned; the same cube opaque: 0, where it was 74 before the skip; none without MSAA). Measured on SwiftShader only; the real-GPU check is manual.
    • exact: every mesh pass computes the key per fragment and writes it as gl_FragDepth, with the key range [−(1024 + 1) × TIE, far + (1024 + 1) × TIE] mapped to [0, 1] (exactDepthRange), so no order is clamped at the near or far plane. Exact frames draw without MSAA: one key per pixel can only be right for every sample when the pixel centre is the only sample, since a grazing face spans a large depth range inside one pixel. On the float path a still frame accumulates 4 shifted passes instead, each with its own centres and keys (Passes). The key is the view depth where the pixel centre's ray meets the triangle's plane (three corners in view space, from a de-indexed copy of the geometry), so any two triangles of one plane, coincident faces of any triangulation included, key a pixel alike. Where that hit lies on or past an outline edge (an edge not shared with a coplanar triangle of the batch: per-triangle flags computed at upload; past it because the rasteriser covers with snapped vertices, which at a grazing angle is far along the plane) a front face keys at the nearer of the hit and the nearest point of the outline, and a back face keys at that outline point plus two pixels' worth of its depth change; shared edges keep the plain hit. Costs early depth testing and MSAA edge smoothing in those frames (a still one gets its edges back over 4 calls). Used for every draw of a frame when the scene has a visible mesh with render order ≠ 0 (decided at scene upload, which then builds the de-indexed geometry and its flags: about 440 ms for load and first frame at the loader's 200 000 triangles on SwiftShader, rt-load-first-frame-200k-order) or when the depth buffer cannot resolve the tie at the far plane (24-bit step (f − n)·f / (n·2²⁴) above a quarter of it: a camera inside the bounding sphere, which builds the de-indexed geometry once, on the first such frame).
    • Limit: on a 16-bit depth buffer (the fallback path's default framebuffer may have only 16 bits) neither variant resolves the tie: the exact key is stored in steps of about (far + 2.05) / 65536 (0.0013 units at far 80), so surfaces closer than a step fall to draw order (higher render order first, then document order).

Lighting and tone mapping

Lights, environment, backdrop, catcher strength, edges and tone mapping come from a look preset (look-presets.md): option look (default DEFAULT_LOOK, Studio, the v0 look), setLook(look) and the look getter. The options background (a flat backdrop, three finite channels in 0..16, else a RangeError) and edges override the initial look's; setLook replaces both with the new look's (setEdges still changes the edges alone). An invalid look throws LookPresetError naming the field and changes nothing. A look of another lightingKey drops the baked light (see Baked lighting); a new sharp angle detects edges again as setEdges does.

Shading is the look's environment fill (hemisphereFill: the sky/ground dome, or for an environment image its L2 spherical-harmonic irradiance from 27 uniforms, MS-47; the preview never loads the image, look-presets.md) plus its directional key, unoccluded without baked lighting and occluded per texel with it, plus the unshadowed accents in both cases, the world-space fill (fillLight) and the view-relative grazing rim (rimLight, its direction turned into world space from the frame's camera): the lightRig chunk of @modelshot/looks, which the path tracer reads too, so an unoccluded surface converges there to the preview's value (path-tracer.md); shaders/shading.glsl applies it. The look's values are uniforms (uploadLookUniforms), uploaded once per program per frame. The backdrop is a pass on both paths (backdrop.frag, the backdrop chunk): the look's gradient in screen y over the frame height, scene-linear into the float path's HDR target, tone-mapped in place on the no-float path; the catcher multiplies the backdrop of its pixel. The HDR result goes through AgX with the look's exposure and grade (#include "agx" from @modelshot/looks, mirrored by agxToneMap for tests), whose output is already display-encoded and is written as is.

Edge rounding

Sharp edges of cubes and meshes catch light as if rounded, without extra geometry (MS-15). The look is EdgeLook of @modelshot/looks (width 0..MAX_EDGE_WIDTH model units, sharpAngleDeg 1..179; EDGE_LOOK_V0 0.5 and 30°), option edges (a Partial<EdgeLook>, out of range a RangeError naming edges.width or edges.sharpAngleDeg) and setEdges(edges) / edgeLook; the path tracer takes the same (path-tracer.md).

  • The edges come from sceneSharpEdges of the document (scene-format.md), at upload; a new pose of the same meshes detects nothing again. batchScene copies a mesh's vertices through vertexOf (positions, normals, UVs, UV rectangles and lightmap data alike) and takes its split indices; the distances and angles go to attribute locations 9 (a_edgeDistances) and 10 (a_edgeAngles, flat), in the exact key's de-indexed copy too. A context restore uploads them again from the retained scene.
  • shading.glsl: prepareShading() runs first in main, before any discard (derivatives need uniform control flow, as prepareDepthKey): each slot's direction toward its edge is minus the in-surface gradient of its distance, from dFdx/dFdy of distance and world position (v_worldPosition), and its footprint is the distance one pixel spans. shadingNormal() replaces the plain normal in the opaque, OIT and fallback passes: the shared roundedNormal (edgeRounding chunk) turns the normal toward each edge as on a circular fillet, sin φ = ramp · sin(θ / 2), so both faces reach the bisector at the edge; the linear ramp is box-filtered over the pixel's footprint, so the band does not shimmer when it is narrower than a pixel. Where no slot bends the normal the function returns it unchanged, so planes, cards and smooth areas are byte-identical with rounding on and off.
  • MSAA shading point (MS-54): the fragment shader runs once per triangle and pixel, by default with its inputs at the pixel centre, which for a pixel the triangle only partly covers lies off the face: the normal, the world position and the edge distances extrapolate there. v_normal, v_worldPosition and v_edgeDistances are therefore centroid (vertex centroid out, fragment centroid in in the opaque, OIT and fallback passes, both depth-key variants; a mismatch fails to link), read at a covered sample as the render's rays hit the face. The centroid values of neighbouring pixels lie up to 0.75 px off the unit steps dFdx/dFdy assume. The directions toward the edges are unaffected (distance and position are differenced over the same steps); the footprint is not, so prepareShading projects v_worldPosition to the drawing buffer (u_viewProjection, u_viewportSize from the viewport chunk) and, where those steps are not unit ones (more than 0.01 px and less than 1 px off, determinant above 1/32), maps the distance gradient back to unit steps with the inverse of their matrix. Opposite sample offsets shorten a step to 0.25 px, and the determinants of the standard 4x pattern's lane steps are multiples of 1/16 (0, 1/16, 1/8, ...): only 0 is singular, so the bound sits halfway below the smallest non-zero one, where float rounding cannot decide. An RTX 3080 gives 0.25 inside faces, and the earlier bound of 0.25 left 24 pixels inside the edges view's sphere at the centroid (docs/testing.md). The baked vertex light (v_vertexLight, v_vertexLightBack, which world charts reuse for their lightmap point) is centroid too: at the pixel centre it would show the light of a point beyond the face. Shading point: a GPU's centroid is a covered sample position, so inside a face, along a quad's diagonal, each triangle would be shaded at a point other than the pixel centre and the pixel would change (measured on an RTX 3080, docs/testing.md). prepareShading therefore steps the normal, the edge distances and, through atShadingPoint, the baked light back to the pixel centre (gl_FragCoord less the sample's projected position, mapped to derivative units; exact for inputs affine in screen space, to first order under perspective), unless the pixel reaches over one of the triangle's sharp edges (some slot's distance at the centre below half its footprint: a sharp silhouette or a crease), where the centroid sample stays. The view direction (towardEye(v_worldPosition)) stays at the centroid, a difference far below a level. Everywhere else the shading point is the centre, also on the silhouette of a smooth mesh, of an open boundary or of a cube edge past the sharp angle, where the centre lies off the face: keeping the centroid there (round 4, a per-triangle stream of corners and neighbour normals that found those silhouette edges exactly) moved 26 pixels of the edges view's sphere outline but matched neither reference better, over its 704-pixel outline band 14.1790 against the render (14.1520 at the centre) and 1.2233 against a 4 x 4 supersampled msaa=0 preview (1.1282), the same on an RTX 3080; the contour facet of a coarse sphere is foreshortened under a pixel, so one shading point, centroid or centre, is as far from the area average, and the coverage error of 4 samples dominates. It was reverted. The step applies beyond 0.01 px, so it also takes up the rasterizer's vertex snapping: SwiftShader snaps to 1/16 px, and its fully covered pixels interpolate up to 0.04 px off gl_FragCoord; they move by that much. sampleBakedLight runs after prepareShading. Only v_uv and v_uvRect stay at the pixel centre: texel anti-aliasing (texelSample), the alpha-to-coverage contour on the texel boundary and the hashed-alpha cell are defined on the texel grid there, and v_uvRect keeps the texel on its face. v_lightmap is flat. No varying is added (the exact variant uses 14 of the minimum 15). Exact-key frames draw single-sampled, where the centroid is the centre, but the snapping correction moves them along rounded-edge bands against origin/main: rt-preset-rim 939 pixels (up to 16 levels, a sub-pixel band on the head's grazing top face), rt-preset-marketplace 1 320 (9), rt-player-classic-back 732 (2). Since MS-35 a still one accumulates 4 shifted passes (Passes), each still single-sampled.
  • Only the normal changes: texels, UVs, alpha and the depth key are untouched. The baked light is computed for the unrounded surface (bake width 0) and multiplies the rounded shading as before.
  • A width change is a uniform; a sharp-angle change detects again and uploads the geometry again.
  • Edge distances and the width are in mesh units, so an animated node scale widens or narrows the band with the node, in the path tracer alike.

Credits: AgX by Troy Sobotka. The Rec.709 inset/outset matrices, the log2 range and the contrast polynomial fit in looks/src/glsl/agx.glsl and looks/src/agxToneMap.ts follow Benjamin Wrensch, "Minimal AgX Implementation" (iolite engine blog, 2024, https://iolite-engine.com/blog_posts/minimal_agx_implementation); the same polynomial ships in three.js AgXToneMapping (MIT) and Filament.

Shaders

GLSL ES 3.00 in src/shaders/*.glsl, composed by linkShader from static chunks only: the renderer's own (texelSample, shading, hashAlpha, bakedLight, depthKey and depthKeyVertex in a raster and an exact variant) and the shared look chunks (agx, texelAlpha, faceTexels, lightRig, shadowCatcher, edgeRounding); the catcher has its own program (catcher.vert.glsl, catcher.frag.glsl). a_lightmap uses attribute location 8, clear of the exact key's 4-7, and the edge attributes 9 and 10, the world chart 11 and the texture flags 12: 13 attributes and 15 varying vectors in the exact variant (raster 10), within WebGL2's guaranteed 16 and 15, the varyings exactly at it (shaderSources.test.ts counts them, each varying as a whole vector). An unknown or cyclic include throws; no shader text is built from user input. A compile or link failure throws RendererError (SHADER_COMPILE / SHADER_LINK) with the driver's info log.

Shipped builds (the app, the embed frame page and the light-bake worker) strip comments and redundant whitespace from every shader source at build time (shippedGlsl of @modelshot/brand, MS-85); the dev server, Vitest and the harness dev server keep the full sources. The strip keeps the line count of each source and linkShader is unchanged, so the linked text of a shipped build has the same line numbers as the linked text of a dev build: a driver message 0:N in a shipped build names line N of linkShader(source, chunks) run on the unstripped sources, for the program and stage in the error label (RendererError of the renderer, PathTracerError of the path tracer; it names program and stage, for example trace fragment of the path tracer). The licence credit in agx.glsl stays in the source and in the Credits paragraph above (Sobotka, Wrensch).

Node matrices may scale (an animated SceneNode.scale, MS-21), so mesh.vert.glsl maps normals with u_normalMatrix, the mat4NormalMatrix of the draw's node matrix (the inverse transpose up to a positive factor, normalised by shadingNormal), never with the upper 3×3 of u_model; the path tracer's flattenScene uses the same helper, so both shade a scaled node alike. One mat3 uniform more, far below MAX_VERTEX_UNIFORM_VECTORS. validateScene rejects a scale that is not finite and positive (nodes[i].scale[k]); animateScene clamps to [10⁻⁴, 10⁴]. An animation frame changes only nodes, so it is a pose-only change, which setScene applies at once (no await needed for it to show): the decoded textures, their alpha tables (no readback), the lightmap layout, sharp edges and the uploaded geometry are kept, validateScene checks only the nodes, and each draw's model and normal matrix (and the bounds of a closed batch) are rewritten from the new world matrices (poseGpuScene). Batches live in node space, so no buffer is touched; a frame costs O(nodes + draws). The call still supersedes a pending setScene, resolves with the warnings of the shown textures, and a lost context restores the newest pose.

The fast path trusts that the meshes, materials and textures arrays are the shown ones (same array objects): content edited inside them is neither re-validated nor re-uploaded, so pass new arrays for a changed mesh, material or texture. The first setScene of a model must be awaited before playback frames are sent: until it has loaded, every frame is a full layout and decode that supersedes the previous one, and the model may never appear.

Limits and warnings

  • setScene validates the document first (validateScene): buffer lengths (3 / 3 / 2 / 4 floats per vertex), indices below the vertex count, groups on whole triangles inside the index buffer, material, texture, node and mesh indices in range or −1, finite node pivot, rotation, translation and restRotation (a NaN would drop the node's subtree from the image and the bounds without a word), renderOrder an integer in ±1024, doubleSided and cameraOnly booleans, texture wrap clamp or repeat (all optional; material alphaMode / alphaCutoff are not checked, resolveMaterialAlpha turns any value into a valid rule). A failure rejects with RendererError INVALID_SCENE naming the path, e.g. meshes[0].indices[5].
  • Textures are checked from the image header in their bytes (PNG signature and IHDR, or a JPEG's first frame header: SOF0-2, 8-bit, 1, 3 or 4 components; of the kind mimeType names) before decoding, never from the size the document declares: each side at most min(maxTextureSide, default 4096, MAX_TEXTURE_SIZE), at most maxTexturePixelsTotal (default 64 Mi) pixels per scene, and the header size must equal the declared size. Decoding uses createImageBitmap on a Blob of the bytes (never a URL, so nothing is fetched); the decoded size is checked again. Textures sharing one bytes object (one image under a nearest and a linear sampler) are decoded, uploaded and counted toward maxTexturePixelsTotal once. A JPEG's Exif Orientation is set to 1 in the copy decoded (browsers apply it even under imageOrientation: 'none'), so every browser decodes it in stored order, as the UVs expect.
  • A texture that is missing, not a PNG or JPEG, too large, fails to decode or whose size disagrees becomes a 2 × 2 grey checker, and setScene resolves with a RendererWarning (TEXTURE_MISSING, TEXTURE_TOO_LARGE, TEXTURE_DECODE_FAILED, TEXTURE_SIZE_MISMATCH, with the texture index); the rest of the model draws.
  • A later setScene or dispose() supersedes a pending one: it resolves with applied: false and closes the images it decoded.
  • The lightmap layout over MAX_LIGHTMAP_TEXELS at the largest downscale rejects setScene with RendererError SCENE_TOO_LARGE before anything is allocated.

Context loss

On webglcontextlost the renderer prevents the default (so the browser may restore it) and render() becomes a no-op. On webglcontextrestored it queries the limits again, rebuilds programs, targets, buffers and textures from the retained scene, decoded images and baked lighting (no re-decode, no re-bake, no reload; the sharp-edge attributes come from the per-mesh cache), redraws and calls the onContextRestored listeners. dispose() deletes every GL object it created (lightmaps and the catcher's quad included), closes every decoded image and removes its listeners.

Clone this wiki locally