Repository navigation
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).
Float path (needs EXT_color_buffer_float):
- With baked lighting that carries one, the shadow catcher (below) right after the clear, without depth test or write.
- 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). - 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. - Composite of the transparency average over the opaque image.
- 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.
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.
setScenelays 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 (faceTexelRangeof 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 thanMAX_CHART_TRIANGLES(32) triangles gets world charts instead (MS-41), when it has at mostMAX_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 isLIGHTMAP_WIDTH(2048) wide; when the charts would need more thanMAX_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 failssetScenerejects withRendererErrorSCENE_TOO_LARGE(pathmeshes) before allocating anything. A scene without a world-charted mesh lays out exactly as before world charts existed (samelayoutKey). -
Values. RGBA16F (
BakedLighting): rgb 1 less the fill lost over the unoccluded fillhemisphereFill(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 overhemisphereFill(up)(Shadow catcher, below).shadeBaked(shading.glsl) isalbedo × (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 (
seamTapsoftexelSample.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) arelerpExact(lerpExact.glsl, a + (b − a) × f, notmix, 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 ashemisphereFill(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 inmesh.vert.glsland interpolated; the back pass reads the back chart. The light is sampled before the firstdiscard(derivatives). -
Kept across poses.
setScenekeeps the lighting when the new document has the samelayoutKey(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.setLightingrefuses (returnsfalse, changes nothing) lighting whose layout key, size or data length does not match the current layout, whoselightingKeyis not the current look's, or whose catcher is malformed (checkLighting). -
Upload in bands.
setLightinguploads nothing itself: eachrender()(oradvanceLighting(), which uploads without drawing and returnstruewhile more remains) writes the nextUPLOAD_FRAME_BYTES(1 MiB) in texSubImage2D bands of at mostUPLOAD_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.
- Textures are uploaded as
SRGB8_ALPHA8withNEARESTfiltering 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
texelFetchtaps (linear colour) blended only within one screen pixel of a texel seam (offsetclamp((p − seam) / fwidth(p), −0.5, 0.5)), with premultiplied weights so a cut-out neighbour adds no dark fringe. Every tap is addressed byfaceTexel(thefaceTexelschunk of@modelshot/looks, which the path tracer addresses with too): clamped to the face's texel rectangle fromSceneMesh.uvRects(faceTexelRange), then, where the face's UVs pass the image, repeated into it for a texture withwrap: '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: bitTEXTURE_FLAG_REPEAT, bitTEXTURE_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: falsesamples 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 (linearTapsandbilinearColorof thetexelFilterchunk of@modelshot/looks, which the path tracer blends with too), each tap addressed byfaceTexelwithin the face's range widened by one texel (linearTexelRange: no seam on a triangle edge two faces share), every tap weighted by its coverage (coverageWeightedoftexelAlpha), 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, sotexelAntialias: falsekeeps it. Four moretexelFetchtaps per fragment (16 minified), only for such textures.
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'stexelCoveragefor 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 underSAMPLE_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 nogl_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 fortexelAntialias: 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-linearmatchespartial-edgewithin 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 nogl_SampleMaskto 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.depthKeyModereports 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 getspolygonOffset(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 asgl_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).
-
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.
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
sceneSharpEdgesof the document (scene-format.md), at upload; a new pose of the same meshes detects nothing again.batchScenecopies a mesh's vertices throughvertexOf(positions, normals, UVs, UV rectangles and lightmap data alike) and takes its splitindices; 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 inmain, before any discard (derivatives need uniform control flow, asprepareDepthKey): each slot's direction toward its edge is minus the in-surface gradient of its distance, fromdFdx/dFdyof 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 sharedroundedNormal(edgeRoundingchunk) 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_worldPositionandv_edgeDistancesare thereforecentroid(vertexcentroid out, fragmentcentroid inin 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 stepsdFdx/dFdyassume. The directions toward the edges are unaffected (distance and position are differenced over the same steps); the footprint is not, soprepareShadingprojectsv_worldPositionto the drawing buffer (u_viewProjection,u_viewportSizefrom theviewportchunk) 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).prepareShadingtherefore steps the normal, the edge distances and, throughatShadingPoint, the baked light back to the pixel centre (gl_FragCoordless 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 theedgesview'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 supersampledmsaa=0preview (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 offgl_FragCoord; they move by that much.sampleBakedLightruns afterprepareShading. Onlyv_uvandv_uvRectstay 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, andv_uvRectkeeps the texel on its face.v_lightmapis 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-rim939 pixels (up to 16 levels, a sub-pixel band on the head's grazing top face),rt-preset-marketplace1 320 (9),rt-player-classic-back732 (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.
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.
-
setScenevalidates 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 nodepivot,rotation,translationandrestRotation(a NaN would drop the node's subtree from the image and the bounds without a word),renderOrderan integer in ±1024,doubleSidedandcameraOnlybooleans, texturewrapclamporrepeat(all optional; materialalphaMode/alphaCutoffare not checked,resolveMaterialAlphaturns any value into a valid rule). A failure rejects withRendererErrorINVALID_SCENEnaming 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
mimeTypenames) before decoding, never from the size the document declares: each side at most min(maxTextureSide, default 4096,MAX_TEXTURE_SIZE), at mostmaxTexturePixelsTotal(default 64 Mi) pixels per scene, and the header size must equal the declared size. Decoding usescreateImageBitmapon aBlobof the bytes (never a URL, so nothing is fetched); the decoded size is checked again. Textures sharing onebytesobject (one image under a nearest and a linear sampler) are decoded, uploaded and counted towardmaxTexturePixelsTotalonce. A JPEG's Exif Orientation is set to 1 in the copy decoded (browsers apply it even underimageOrientation: '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
setSceneresolves with aRendererWarning(TEXTURE_MISSING,TEXTURE_TOO_LARGE,TEXTURE_DECODE_FAILED,TEXTURE_SIZE_MISMATCH, with the texture index); the rest of the model draws. - A later
setSceneordispose()supersedes a pending one: it resolves withapplied: falseand closes the images it decoded. - The lightmap layout over
MAX_LIGHTMAP_TEXELSat the largest downscale rejectssetScenewithRendererErrorSCENE_TOO_LARGEbefore anything is allocated.
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.
Repository · This wiki is generated from docs/: change the pages there.