Skip to content

About

Optimized Three.js model viewer for monkstudeyo.com

Resources

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

monk-viewer

model-viewer (https://modelviewer.dev/) is a beautiful project.

But I wanted something more specific.

Let's grab three.js and see what we can do.

Welcome monk-viewer!

Cool features:

  • Offscreen Canvas + Web Workers! No more jank in the main thread — keeps your UI buttery smooth. Although for very slow devices, it won't really help. But hey, no more jank when uploading textures to the GPU, which happened a lot for my models using model-viewer.
  • Performance Auto-Tuning: Detects device power in the first 1500ms after firing the renderer. Below 54 FPS it drops MSAA first (TAA alone is the best quality per millisecond) and measures again, then lowers the resolution only if it's still too slow.
  • Total Three.js Control: Full access to the rendering pipeline — custom shaders, post-processing, etc.
  • Ultra HDR Environment Maps: Loads Adobe Gain Map JPGs, courtesy of MONOGRID gainmap-js
  • Smooth 3D Interaction: Custom orbit controls with damping, auto-rotation, and slick transitions. Everything is smooth: camera interaction, model rotation, enabling/disabling interactions, interrumpting rotations or movement.
  • Tonemapped MSAA Resolve: With an HDR pipeline and a high-contrast scene, hardware MSAA averages samples in linear HDR, so a half-covered pixel of a bright edge still tonemaps to pure white and edges stay jagged no matter the sample count. Opaque materials write a Reinhard-encoded colour approximating the final tone mapping, the hardware resolve averages roughly what ends up on screen, and TonemappedResolvePass decodes back to linear HDR before bloom and tone mapping. Transparent objects (the glass) are drawn after the decode, since blending doesn't commute with the encoding. Combined with 4x MSAA, thin bright edges come out close to 16x supersampling. The lab's Edge resolve section tunes it: the curve (per channel, on the largest channel to keep the hue of coloured edges, or off for plain MSAA) and the exposure of the MSAA and TAA encodings. With GT7 the Reinhard encoding is an approximation (it must stay invertible for bloom, and GT7 clips): it still removes about two thirds of the edge error of plain MSAA.
  • Temporal Antialiasing: TemporalAAPass jitters the camera and accumulates frames on top of the 4x MSAA, smoothing the sub-pixel steps MSAA leaves on thin edges. Reprojection uses depth plus the model's rigid motion (no velocity buffer needed for one object in front of a static background). While nothing moves, the history becomes a running average over all jitter positions without clipping, so still frames converge to a steady, render-like image instead of flickering with the jitter cycle. The history lookup intentionally stays on the same pixel instead of compensating the jitter offset: that is what averages the jitter positions, so still frames converge. Compensating the offset instead tracks the surface sub-pixel by sub-pixel and leaves the 1px jitter visible in still frames (aa-lab.html has the toggle; it needs ~0.65px jitter to hide and is not better in motion).
  • Bounded TAA: Before bloom, pixels outside the tracked model's padded accumulated screen bounds are known black, so TAA skips their depth/history/filter work. Bounds include the transparent glass, not just opaque depth. This preserves the glass material, MSAA, TAA reconstruction and eight-level bloom. The footprint only grows until the history is reset, so faint motion trails are preserved too. Near-plane crossings, coloured/textured backgrounds, fog or disabled history clamping expand it to full-screen TAA until the next reset. It assumes all rendered geometry belongs to the tracked rigid object, without vertex displacement; standalone TemporalAAPass users must opt in with backgroundFastPath: true.
  • Bounded Bloom: BoundedBloomEffect reuses TAA's certified black-background footprint to skip zero-valued luminance and downsample work. Conservative filter padding covers the complete downsample chain, including odd dimensions; all eight levels, thresholds, buffer formats and the upsampling filters are unchanged. It falls back to full-screen work when TAA cannot certify the input, so glass reflections and the wide halo are preserved.
  • Grazing-Angle Specular Clamp: Seen edge-on, the 2.4mm edges of the frame shrink to sub-pixel slivers while Fresnel makes their reflection the brightest in the scene: thin HDR lines that stay jagged whatever the sample count. GrazingSpecularClamp patches the materials to compress the environment reflection above a low limit only on surfaces seen at a grazing angle (from the face normal, so normal maps don't make it noisy). Not physically based, but neither are the stairs. Off by default since specular antialiasing (below) prefilters the same highlights without dimming them; grazing-specular-clamp="true" enables it.
  • Modern TAA: 3x3 variance clipping towards the current colour, history validated per pixel by depth and by motion (a part lying flush on another slides off without leaving its history behind), FSR 2 style thin feature locks, a reactive mask for the glass, edge-adaptive blend and per-frame sample confidence. A velocity buffer from the geometry pass gives every mesh its own motion, so parts animated on their own reproject correctly.
  • Specular antialiasing: roughness widened by the screen-space variance of the normal (Tokuyoshi & Kaplanyan 2019, as in Filament), so sub-pixel bevels don't alias into bright stairs. It only reacts to normals that vary within a pixel: a flat face, like the 2 mm edge of the glass, gets nothing from it.
  • Edge reflections (native WebGPU, EdgeReflections in src/webgpu/Geometry.js, on by default): the bright sub-pixel lines along silhouettes and edge-on surfaces. Seen exactly edge-on, a surface's normal is perpendicular to the view ray, Fresnel is ~1 and the mirror reflection continues straight along the ray: it shows whatever is behind the object. In a scene whose background is visible (a game with screen-space reflections), that matches the neighbouring background pixels, so the edge can't alias however thin it is. Here the environment lights the model but the visible background is black, so the physically correct grazing reflection is a bright line on black, the worst case for antialiasing. Three fixes, each with a lab control in the Edge reflections section:
    • Corrected normals: interpolated and normal-mapped normals near a silhouette often face slightly away from the camera. three clamps N·V to 0 (full Fresnel) and the reflection vector bends back to an unrelated, often bright, part of the environment. The shading normal is tilted towards the view just enough to keep N·V ≥ 0.235 (tuned in the lab), so surfaces within ~14° of edge-on stop bending their reflection back; what they still reflect near the view ray is faded by the background match.
    • Background match: the environment reflection fades towards the black background as its direction approaches the view ray (reach 0.18: surfaces within ~17° of edge-on, strength 1). Faces seen head-on or at moderate angles are unchanged. Unlike the grazing clamp it keys on where the reflection points, not on how bright it is.
    • Ground glass edges: the glass pane's thin side faces (found from its thinnest bounding box axis, so no mesh names) get roughness 1, like ground framing glass. Polished, they mirrored the environment's lamps into very bright lines that came and went at certain angles: the edges are seen at 20–40°, outside any grazing fix, and the glass is drawn after the MSAA resolve, single-sampled, so only TAA antialiased them. Hiding the glass mesh was what pinned the lines on it rather than the metal plate or the clips.
  • Motion blur: McGuire 2012 / Jimenez 2014 tile-based reconstruction from the per-pixel motion, on by default (motionBlur: false in the renderer options, or motionBlurPass.enabled, turns it off). On the native WebGPU backend it also works with TAA off: the disabled TAA pass still writes the per-pixel view depth and per-mesh motion the blur reads, so the blur keeps its per-object nature (parts moving on their own blur on their own) and is skipped in frames without any motion. The WebGL reference skips the whole TAA pass when it's disabled, so there it still needs TAA.
  • MSAA levels in the lab: off / 2x / 4x / 8x buttons (?msaa=8, key M toggles on/off). WebGPU only allows 1 or 4 samples per pixel, so the native backend builds the others from two draws whose resolves are averaged (in the encoded space, so the tonemapped resolve still holds): 8x is two 4x MSAA draws offset by ±1/16 px, whose union is 8 samples in 8 distinct rows and columns like the D3D 8x pattern, at twice the geometry cost; 2x is two single-sample draws at the standard 2x positions, which is 2x supersampling (shading is sampled twice too, so a little better than real 2x MSAA and dearer than native 4x). WebGL uses real hardware samples up to the context's MAX_SAMPLES. The viewer stays at 4x.
  • Gran Turismo 7 tone mapping: a port of Polyphony Digital's MIT-licensed sample implementation from the SIGGRAPH 2025 talk Driving Toward Reality. The curve runs per channel, then the result is blended with one that keeps the input's hue and chroma in ICtCp, so bright colours roll towards white without the hue shifts of plain per-channel curves, and the blacks stay deep instead of Reinhard's lifted, milky look. The lab's GT7 toggle (key K) compares against Reinhard. On top of the reference, GT7ToneMappingEffect exposes the curve and a grade: blackPoint (crush or lift), shadowToe and toeLength for the shadows, shoulderStart and highlightHardness for the roll-off (0 approaches white without clipping, keeping highlight detail like Reinhard; GT7's 0.25 clips), chromaPreservation (how much bright colours keep their hue instead of burning to white), plus contrast, saturation and vibrance. The effect's defaults are GT7's; the viewer uses a grade made in the lab (softer, never-clipping highlights, a slightly lifted black), where each has a slider and copy settings exports them. Costs ~0.2 ms at 4K on an RTX 3080.
  • Environment background (native WebGPU, off by default): environment-background="true" on <monk-view> (or setEnvironmentBackground(true), or the lab's Background section) shows the HDRI instead of black; environment-background-blur (0 sharp to 1, the PMREM level) and environment-background-intensity tune it. three's own background can't be used: the geometry pass writes the tonemapped (encoded) colour for the MSAA resolve, so it would decode wrong, and it has no motion vectors. The geometry stage draws it instead, as a fullscreen triangle at the far plane drawn last in the opaque pass: encoded like the opaque materials, so silhouettes against it resolve like any other edge, with a camera-only velocity (direction at infinity) so TAA reprojects it and the motion blur blurs it with the camera. Its depth stays at the far plane, so the glass and TAA still see background there. With it, TAA and bloom give up their black-background bounds and work full screen (more GPU time), and the edge reflections' background match turns itself off: a grazing reflection then already matches the pixels around it, as in a game. The WebGL reference stays black.
  • Resolution-independent bloom: the mip chain follows the output height, so the bloom keeps its reach at any render scale or pixel ratio.
  • Startup Transition: smooth animations = ♥ UX
  • Augmented Reality (AR): Auto-detects AR support on iOS/Android and adds a quick link to jump in, loading optimized GLB or USDZ files.
  • USDZ files should use the latest Apple schemas, converted from GLB using Reality Converter in MacOS. Ambient Occlusion maps work!
  • Web Components API: Drop in a <monk-view> tag, tweak it with attributes, and go.
  • Custom Events: interactivity and loaded
  • Post-Processing: Using v7 alpha version of pmndrs/postprocessing, which allows for easier effect chaining + better performance. Also added a custom dithering pattern which works better for our bloom + black background scene.
  • Vite browser compile optimized modules. No repeating code. Same scripts for worker and non-worker code (this one was tricky).

Additional contribution to @monogrid/gainmap-js which made it compatible with web workers ♥

Known issues

  • Chrome precise FPS measurement is just pain. It is not possible. Android devices used to get MSAA disabled and a 2x pixel ratio cap up front; they have improved enough that they now get the same settings as iOS and desktop (4x MSAA + TAA), and the auto-tuner drops MSAA, then resolution, on the ones that can't keep up. Firefox on Mac and Android used to get MSAA disabled (and half resolution with offscreen canvas) from a user-agent check; that predated the auto-tuner and the native WebGPU backend, and is gone: every browser starts the same.
  • The environment background is native WebGPU only, like the edge reflection fixes.
  • The edge reflection fixes are native WebGPU only; browsers that fall back to the WebGL reference still show the polished glass edges and the uncorrected grazing reflections.
  • postprocessing's SMAAEffect doesn't work in offscreen canvas mode: it decodes its lookup textures with Image, which workers lack. src/postprocessing/SMAAEffect.ts decodes them with createImageBitmap instead (opt-in via the smaa="true" attribute, off by default: it doesn't improve the thin bright edges beyond TAA).

Performance checks

npm test checks conservative TAA bounds, bloom filter support across rounded mip sizes, composition settings, safe fallback guards and the GT7 tone mapping against Polyphony's reference (Node 22.6+ with TypeScript stripping).

For GPU measurements, run npm run dev, open /monk-viewer/aa-lab.html?fixed=1&smaa=0 (the deployed copy is at https://arecsu.github.io/monk-viewer/aa-lab.html), choose still motion and use the browser console. fixed=1 pins the deterministic 900×600 @1x framebuffer the numbers here assume; without it the lab fills the viewport at the device pixel ratio.

aa-lab-pickets.html is the same lab on the test scene of pmndrs/upscaler's FSR1 vs FSR3 example (thin pickets, a spinning metal knot, a high-frequency grid floor; same camera, auto orbit and DPR cap of 2), so our TAA/MSAA/SMAA can be compared side by side with that page. The panel's benchmark button pauses the loop, renders frames back to back and drains the GPU queue: the frame cost without vsync, on either backend. Live GPU timestamps are not used because on Apple GPUs the per-pass timestamps overlap and do not add up to a frame time.

Both labs have a Temporal upscaling section (native WebGPU only): the geometry and glass render at a fraction of the output (FSR's 1.5×/1.7×/2×/3× ratios or the slider) and TAA reconstructs the output-resolution history from them. Each frame's samples are weighted by their sub-pixel distance in output pixels and its contribution by how close its nearest sample lands (min weight is the floor), the jitter cycle grows to FSR's 8 × ratio² phases, and pixels without usable history fall back to a bilinear upscale. The native TAA keeps its own unchanged shader; see docs/webgpu-performance.md for what upscaling does and doesn't save in this pipeline.

const checks = await import('/monk-viewer/tests/taa-browser.js');
console.table(await checks.validateTaa(window.mgr, window.THREE));
console.table(await checks.benchmarkTaa(window.mgr, { pixelRatio: 1 }));
// Repeat at higher resolution, without changing CSS layout:
console.table(await checks.benchmarkTaa(window.mgr, { pixelRatio: 2 }));

The framebuffer checks compare bounds on/off through rotation, camera orbit, pan, near-plane clipping, resize/DPR changes and fallback settings. The benchmark uses asynchronous EXT_disjoint_timer_query_webgl2 queries, warm-up frames and alternating A/B order, reporting GPU milliseconds rather than vsync-limited FPS. It rejects disjoint measurements; results vary by GPU/browser. The lab's TAA bounds toggle also permits visual comparison. Bloom checks compare the optimized effect against the unmodified library BloomEffect, not against fewer levels or a smaller halo:

const bloom = await import('/monk-viewer/tests/bloom-browser.js');
console.table(await bloom.validateBloom(window.mgr, window.THREE));
console.table(await bloom.benchmarkBloom(window.mgr, { pixelRatio: 1 }));
console.table(await bloom.benchmarkBloom(window.mgr, { pixelRatio: 2 }));

The bloom suite covers motion, odd/tiny/portrait sizes, resolution scaling, different mip counts/radii, full-resolution upsampling and disabled-TAA/background fallbacks. The lab's bloom bounds toggle disables only the optimization, not bloom itself. Savings depend on how much of the image TAA can certify as black; a full-screen history footprint safely gives up the optimization.

The GT7 shader is checked against its CPU port (which npm test checks against Polyphony's reference, built in double precision):

const gt7 = await import('/monk-viewer/tests/gt7-browser.js');
console.table(await gt7.validateGT7(window.mgr, window.THREE));

A native WebGPU backend now exists (Three.js node materials and TSL, no WebGL anywhere in it): encoded 4× MSAA geometry with per-mesh velocity, explicit sample-0 depth for glass and TAA, the full temporal/bloom/GT7/dither/SMAA chain, and native gain-map environment loading. auto (the default) selects it wherever the browser exposes WebGPU and falls back to the WebGL reference when WebGPU is missing, fails to initialize or reports a recoverable runtime error; backend="webgpu" on <monk-view> (or ?backend=webgpu in the lab) forces it, and webgl forces the reference. See docs/webgpu-native.md for the architecture, backend selection and limitations, and docs/webgpu-parity.md for the measured WebGL-vs-native parity and whole-pipeline throughput. The earlier geometry-stage experiment remains as the isolated-stage evidence it is.

Using it

Method 1: Build

git clone https://github.com/Arecsu/monk-viewer.git
pnpm install
pnpm run build

Inside dist/assets/, you will find:

monk-viewer-1.1.0.js
offscreencanvas-worker-1.1.0.js
renderer-1.1.0.js

In your HTML document

<script crossorigin type="module" src="monk-viewer-1.1.0.js"></script>

And serve offscreencanvas-worker-1.1.0.js and renderer-1.1.0.js from the same path as monk-viewer-1.1.0.js for them to be imported by monk-viewer-1.1.0.js correctly.

The HTML component:

<monk-view 
  id="viewer"
  model="False_Enlightenment.glb"
  startup="auto"
  min-distance="0.4"
  target-distance="0.8"
  max-distance="2.0"
  perf-stability-duration="1.0"
  perf-measure-duration="0.5"
  init-delay="0"
  init-delay-interactive="0.6"
  offscreencanvas="true"
  model-title="False Enlightenment"
  model-glb="False_Enlightenment.glb"
  model-usdz="False_Enlightenment.usdz"
  ar-vertical="true"
  envmap="old_bus_depot_2k_HDR.jpg">
</monk-view>
  • startup: 'auto' or 'manual'. 'auto' means the renderer and scene will start as soon as assets are loaded. With 'manual' you have to attemptInit() at some point. The models and assets will preload in both scenarios.

  • offscreencanvas: 'true' or 'false'. Recommended to leave it enabled. Will fallback to a normal canvas if for some reason it can't use offscreen canvas. No reason to not use it, unless researching purposes? Although what's being mentioned on Known Issues might be semi-valid concerns.

There are also attributes that will be set across the element's lifespan:

  • ready: 'true' or null. Appears when the assets have been loaded and ready to be rendered.

  • loaded: 'true' or null. Appears when the renderer and scene have been executed.

  • interactive: 'true', 'false' or null. Triggered when clicked on the element to interact with it.

CSS:

monk-view,
monk-view canvas {
  width: 100%;
  height: 100%;
  display: block;
}

monk-view canvas:focus {
  outline: none;
}

monk-view {
  --delay-model: 660ms;
  --delay-ar: 3000ms;
  position: relative;
}

monk-view canvas {
  transition: opacity 4000ms var(--delay-model) ease-in-out;
  opacity: 0;
}

monk-view[loaded="true"] canvas {
  scale: 1;
  opacity: 1;
}

monk-view .ar-link {
  position: absolute;
  font-size: 1rem;
  right: 1.2rem;
  top: calc(100dvh - 3rem);
  padding: 5px 10px;
  background: hsl(0, 0%, 16%);
  border-radius: 0.2em;
  color: white;
  text-decoration: none;
  opacity: 0;
  transition: opacity 1500ms var(--delay-ar) ease-in-out;
}

monk-view[loaded="true"] .ar-link {
  opacity: 1;
}

Events

There's the events you can make usage of:

const monkView = document.getElementById('viewer');

monkView.addEventListener('interactivity', (e) => { // triggers false or true depending on interactivity changes
   const isInteractive = e.detail.state; // true or false
   isInteractive ? disableDragSlider(slider) : enableDragSlider(slider)
});

monkView.addEventListener('loaded', (e) => { // triggers when the renderer and scene have been executed
   document.querySelector('.loading-monk-view').textContent = 'LOADED'
})

In case of using startup="manual":

let observer
const monkView = document.getElementById('viewer');

customElements.whenDefined('monk-view').then(() => {
  observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {

        // If assets are loaded, renderer will start.
        // If not, will do it as soon as assets are ready
        monkView.attemptInit();

      } else {

        // If the renderer has not yet started, will prevent it to execute. 
        // Won't stop execution of an already executed renderer
        monkView.abortInit(); 

      }
    });
  }, { threshold: 0.5 });

  observer.observe(monkView);
});

monkView.addEventListener('loaded', (e) => {
  if (observer) observer.disconnect();
});

Method 2: custom project

This is not really a library. It should be manually copy-pasted and used in a given project.

  • src/main.ts is the starting point and Web Components API declaration. It will invoke the renderer if no offscreen-canvas can be used. Otherwise, it will use offscreencanvas-worker to proxy between it and renderer
  • offscreencanvas-worker is the worker and proxy between JS main thread and renderer
  • renderer is the main 3D scene and logic
  • OrbitControls.js is a fork of Three.js OrbitControls with bug fixes and quality of life modifications to add smoother interactions and animations. Makes usage of Damper.ts which is derived from @google/model-viewer project.

About

Optimized Three.js model viewer for monkstudeyo.com

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages