Skip to content

Repository files navigation

DisplayXR Demo — 3D Model Viewer

Real-time PBR model viewer for glasses-free 3D displays, built on the DisplayXR runtime via OpenXR with Vulkan. Loads glTF 2.0 (.glb / .gltf), STL, OBJ, FBX, and USD (.usdz / .usd / .usda / .usdc) models and renders them with asymmetric per-eye Kooima projection for the full multiview 3D experience.

Loads metallic-roughness PBR materials with textures and image-based lighting, a blurred procedural skybox, and a transparent see-through mode (Windows). The bundled sample is the Khronos DamagedHelmet, auto-loaded at startup.

Requires the DisplayXR runtime v1.9.1 or newer (Windows) / the latest macOS runtime .pkg. Download the matching installer from the displayxr-runtime releases page: DisplayXRSetup-*.exe on Windows or DisplayXR-Installer-*.pkg on macOS. v1.9.1 ships the Vulkan transparent-window bridge + in-place resize this demo relies on; older runtimes produce a broken/black window or flicker on resize. The shell (displayxr-shell-releases) is optional — only needed for the spatial workspace shell.

Supported formats

Format Extensions Materials Notes
glTF 2.0 .glb .gltf Full metallic-roughness PBR + textures Reference path
STL .stl Neutral default material Binary + ASCII; geometry only
OBJ .obj (+ .mtl) Phong → metallic-roughness shim Best-effort material fidelity
FBX .fbx PBR maps, Phong fallback Skinned + animated (auto-plays first clip); no blend shapes yet
USD .usdz .usd .usda .usdc UsdPreviewSurface PBR Base-color/emissive textures + PBR factors; normal & metallic-roughness maps not yet honoured

Every format feeds the same renderer (metallic-roughness PBR + image-based lighting). Not yet supported: Draco mesh compression and KTX2 / Basis textures (textures are PNG/JPEG only). See PORTING.md for the per-backend breakdown and roadmap.

Material feature support

The renderer implements core glTF metallic-roughness plus every KHR_materials_* extension listed below. Anything not implemented still loads and renders, but only its base layer does.

That fallback is what the spec prescribes, but it is never silent. Anything the asset declares in extensionsUsed that the renderer lacks is listed on stderr at load and summarised in the HUD (! ignoring: clearcoat, sheen, +3 more). A viewer whose job is "does this material match the authoring tool" must not let a dropped extension be mistaken for a renderer or display difference.

Feature Status
Base colour (factor + texture, sRGB-decoded) ✅
Metallic-roughness (factors + combined texture) ✅
Normal map (tangent-free, no TANGENT attribute needed) ✅
Occlusion, emissive ✅
Image-based lighting (irradiance + prefiltered specular + BRDF LUT) ✅
KHR_materials_ior ✅ — drives dielectric f0 instead of the old hard-coded 0.04
KHR_materials_specular ✅ factors + textures (specularTexture A, specularColorTexture RGB)
KHR_materials_clearcoat ✅ factors + textures (clearcoatTexture R, clearcoatRoughnessTexture G). No clearcoatNormalTexture
KHR_materials_sheen ✅ factors + textures (sheenColorTexture RGB, sheenRoughnessTexture A), Charlie + Ashikhmin with spec energy compensation
KHR_materials_emissive_strength ✅
KHR_materials_anisotropy ⚠️ factors — spec D/V, direct light only (see below)
KHR_materials_iridescence ✅ factors — full thin-film model, no thickness texture
KHR_materials_transmission ✅ factor + transmissionTexture (R) — refracts the rendered scene, roughness-blurred
KHR_materials_volume ✅ factors + thicknessTexture (G) — thickness-driven refraction + Beer-Lambert attenuation
KHR_materials_coat (draft) ✅ factors + textures (coatTexture R, coatRoughnessTexture G, coatColorTexture RGB, coatAnisotropyTexture B/RG, coatNormalTexture) — coloured tint, darkening, anisotropy, tunable IOR, and a coat-only shading normal. Every property in the spec's table is implemented
KHR_materials_diffuse_roughness (draft) ⚠️ factor + diffuseRoughnessTexture (R) — Fujii energy-preserving Oren-Nayar for direct light; IBL uses the spec's normal-bend approximation
KHR_materials_fuzz (draft) ⚠️ factors + textures (fuzzTexture R, fuzzColorTexture RGB, fuzzRoughnessTexture A) — replaces sheen, layered above the coat, weight separate from colour. Limited by the sheen LUT, see below
KHR_materials_scatter (draft) ⚠️ factors + scatterStrengthTexture (A) + multiscatterColorTexture (RGB) — subsurface/multiple scattering. Volumetric mode is approximated with the spec-sanctioned thin-walled model; see the limits below
KHR_texture_transform ✅ offset / rotation / scale, per texture slot (all 15). texCoord overrides ignored — single UV set
Draco, KTX2/Basis ❌

Scatter is an approximation, and here is exactly where it stops. The spec permits renderers without full volumetric transport to approximate volumetric mode with the thin-walled model, which is what this does: a Lambertian lobe of the multi-scatter albedo, split by anisotropy into a forward half (the most-blurred scene copy, standing in for diffuse transmission) and a backward half (a diffuse reflection). How much of the light takes that path is driven by optical depth thickness / attenuationDistance, so density still matters. Measured against the Khronos conformance assets (ScatterColorAndDensity, rasterizer reference), the density response rises with density in both, and ours saturates where the reference keeps grading — sampling the leftmost multiscatterColorFactor column sparse→dense gives ours 141 → 182 → 181 → 181 against the reference's 98 → 123 → 141 → 135, i.e. ours is flat after the second step. Read those as a direction, not a score: the absolute values are not comparable (the reference is lit by an outdoor HDRI, we use the procedural analytic sky) and the percentages move with where on the sphere you sample. The second known gap is that the result is over-saturated — real multiple scattering desaturates as it redistributes energy between bounces, and one Lambertian bounce cannot.

These were re-measured after the inverted-normal fix (#87); the figures quoted here before that landed were taken with Fresnel pinned at grazing on every material and should not be cited.

The obvious suspect for that second gap is the spec's Kulla-Conty multi→single scatter albedo remap, which this deliberately does not apply to the lobe. It was implemented and measured, not waved away: mean per-channel error against the reference is 25.2 with the authored multi-scatter albedo vs 43.1 with the remapped single-scatter one, and at high anisotropy the remap sends the hue blue (g=+1 → (153, 175, 194) against a reference of (75, 70, 57)). That is the expected result once you notice the remap exists to derive transport coefficients: one bounce carrying the multi-scatter albedo approximates the converged multi-bounce appearance, whereas one bounce carrying the single-scatter albedo under-counts every bounce after the first. Re-measure it any time with DXR_MODELVIEWER_KULLA_CONTY=1.

Sheen conserves energy. The base layer is scaled by 1 - max3(sheenColor) · E before sheen is added, where E is the sheen directional albedo — the hemispherical integral of the same Charlie/Ashikhmin pair the shader evaluates, baked into a 64² table at startup (shaders/sheen_lut.frag). Evaluator and integrand share shaders/sheen.glsl, so the table cannot drift from the BRDF it is meant to integrate.

Coat replaces clear coat, and reuses its lobe. KHR_materials_coat is a superset that the spec maps KHR_materials_clearcoat onto 1:1, so the loader folds clearcoat's five properties into the coat fields and one shader lobe serves both — a hasCoat flag gates only what coat adds. A clearcoat-only asset therefore takes IOR 1.5 (f0 = 0.04, the constant the lobe used to hardcode), white tint, no darkening and no anisotropy, and renders byte-identically to the pre-coat build: mean 0.000 over 11,059,200 channels of assets/material_grid.glb, and 0.000 on each of its ten rows individually. Coat takes precedence where a material carries both, per spec.

Three notes where we knowingly diverge or where the draft is self-inconsistent, all raised upstream:

  • coatDarkeningFactor defaults to 0 for a clearcoat-only asset, not the spec's 1. Darkening is physically correct and coat turns it on, but clearcoat never had it, so applying it to a clearcoat asset would silently restyle it. With KHR_materials_coat present the spec's 1.0 applies.
  • Darkening is gated by coat weight. The spec's composition applies coatColor × coatDarkening to the base outside the weighted mix, so a material with coatFactor: 0 would still be tinted and darkened by a coat that is not there. We scale by the weight instead.
  • The spec's hemisphere average is 6.3x too high. It computes the hemisphere-averaged reflectance for darkening as F_0 + 0.5*F_90 = 0.54, describing it as "halfway between F_0 and F_90" — but that is not a hemisphere average, which for a Schlick Fresnel is F_0 + (1-F_0)*0.0476 = 0.086. Taken literally it darkens ambient light 48% against direct light's 8%, putting a hard dark band on the unlit side of every coated surface. We use the true average; DXR_MODELVIEWER_COAT_SPEC_HEMI=1 restores the literal formula.

Diffuse roughness is a separate roughness. The diffuse substrate gets a microfacet model instead of pure Lambert, so a rough diffuse surface back-scatters at grazing angles and flattens out — sandstone rather than matte plastic. Direct light uses Fujii's energy-preserving qualitative Oren-Nayar, not the EON model (arXiv 2410.18026) the spec points at; the spec explicitly permits the substitution ("Implementations of the BRDF itself can vary based on device performance… there is no single micro-facet model we can use as a ground truth"). IBL uses the spec's third option, bending the shading normal toward the view — which the spec itself calls the least correct and most performant of the three, the other two meaning a rebuilt IBL pipeline for one draft extension. Roughness 0 returns the Lambertian path unchanged.

Spec defect: diffuseRoughnessFactor defaults to 0.0 in the README property table and 1.0 in the JSON schema. We take 0.0 — 1.0 would restyle every existing asset.

Fuzz replaces sheen, and reuses its lobe and its table. fuzzColorTexture and fuzzRoughnessTexture sample the identical channels as their sheen counterparts, so fuzz rides the sheen lanes and slots; only the weight and a hasFuzz flag are new. The two real differences are both implemented: fuzz sits above the coat where sheen sits below it, and its weight is separate from its colour, so fuzz can be darker than what it covers. Black soot is the motivating case, and it is inexpressible in sheen — a black sheen colour just switches the layer off. Measured on assets/diffuse_fuzz_test.glb, the same white→black colour sweep written both ways: sheen −31.0 luma converging on the bare base, fuzz −56.9 continuing past it into soot.

A note that used to sit here claimed the sheen/fuzz directional albedo saturated below roughness ~0.5 and that a dark fuzz therefore nulled the surface. That was issue #87, not a lobe problem — ndotv was pinned at its clamp, so every LUT sample landed on the grazing edge where a directional albedo legitimately approaches 1. Head-on with #87 fixed, E runs 0.000 at roughness 0.05 to 0.148 at 1.0. Issue #85 was closed as invalid.

Spec defect: fuzzRoughnessFactor defaults to 0.5 in the README table and 0.0 in the schema. We take the schema.

Texture-driven variants are read for coat, clear coat, sheen/fuzz, diffuse roughness, specular, transmission and volume — each samples the channel its extension specifies and multiplies the corresponding factor. Absent maps bind to 1×1 white, the multiplicative identity, so a factors-only material behaves exactly as before. Still not read: clearcoatNormalTexture (coat's equivalent IS read), anisotropyTexture, iridescenceTexture and iridescenceThicknessTexture. Those are partial implementations rather than missing ones, so they do not trip the ignored-extension warning — this table is the reference.

Set 1 now binds 19 samplers and set 2 binds 5. Vulkan only guarantees 16 per stage, so the renderer logs its budget against the device's actual limit at startup (sampled images per stage: need 24, device allows …) rather than letting a constrained device fail with an opaque pipeline-layout error.

That slot count and the material SSBO's two trailing UV-transform arrays are sized from a single #define in model_common/shaders/material_slots.glsl, included by both pbr.frag and model_renderer.h, with static_asserts tying the enums and sizeof(MaterialExtGpu) to it. The count sets the struct's stride, and when the two sides disagree only material 0 reads correctly — which looks like whole-image corruption, not like a texture-slot problem. That is issue #81; it cost a reverted branch to diagnose, and is now a build error.

Transmission costs a scene-colour copy per view. Refracting the rendered scene (rather than only the environment, which the spec calls out as falling short) means the opaque pass has to be captured before transmissive surfaces are drawn. That copy happens once per renderEye — i.e. once per view tile in the atlas — so its cost scales with view count, which is the one place where driving a multiview 3D display genuinely changes the renderer's budget. It is skipped entirely when no loaded material transmits, and in transparent-background mode (where there is no opaque scene to refract, so glass falls back to IBL).

What is captured is scene-linear radiance, not the displayed image. The opaque pass writes two colour attachments: the one that reaches the swapchain (tone-mapped, and sRGB-encoded when the swapchain is UNORM) and a 16F twin holding the same shading before the tone curve and the encode. Transmission mips a copy of the twin. Capturing the displayed image instead — which is what the renderer did up to v0.19.1 — put an already-graded value into a still-linear color that then ran the whole grade again, so glass rendered washed out and desaturated (issue #75). Sampling in radiance also means the baseColorFactor tint, Beer-Lambert absorption and the diffuse-lobe replacement act on radiance, which is the only space in which any of them means anything, and makes the roughness mip chain a linear box filter rather than an average of encoded values.

To check it, run with DXR_MODELVIEWER_TRANSMISSION_PROBE=1: every transmissive surface then outputs its raw scene sample through the shader's own display transform instead of shading, so on assets/transmission_test.glb all six transmissive spheres must reproduce the backdrop behind them and vanish, leaving only the opaque control. scripts/check_transmission_probe.py measures that.

Documented limitation — anisotropy is direct-light only. The extension's distribution and visibility terms are implemented verbatim, but anisotropy is not applied to image-based lighting. The standard trick — bending the IBL reflection vector toward the stretch direction — is implemented and then deliberately disabled: it produces a hard vertical pinch on a sphere, and the distortion is non-monotonic (strength 0.17 looks far worse than 1.0). The cause is not the tangent frame; authoring TANGENT (which this viewer now reads) disproved that hypothesis. Bending the reflection swings it across the procedural sky's hard sky/ground horizon, and a two-tone environment turns a smooth stretch into a visible seam. Anisotropic IBL is not spec text, so given the choice between a visible artifact and an under-stated effect, a material-fidelity viewer takes the under-stated one. Worth revisiting under a real HDRI, which has no hard horizon for the bend to cross. Consequence: on a metal lit mostly by an environment, anisotropy is measurable but close to invisible.

TANGENT is read when present. The glTF TANGENT attribute now feeds the shading frame, with the screen-space-derivative frame as the fallback for assets that ship none. This is the correct source for normal mapping as well — the derivative frame flips across UV seams and degenerates at poles — and the material grid authors analytic tangents for its spheres.

Both anisotropy and iridescence are subtle in the material grid, and that is physical rather than a defect. Anisotropy for the reason above; iridescence because the grid's row is a 4 % dielectric under a broad sky, where thin-film interference shifts the reflection only slightly. Pixel probes across each row confirm both vary monotonically with their sweep.

Tracking issue: #70 — OpenPBR reference scene and material interoperability.

Authoring in OpenPBR? docs/openpbr-to-gltf.md records what survives the export, what arrives approximated, and what glTF has no slot for at all — so a difference between the authoring tool and the viewer can be attributed to the export, the format, or the renderer rather than guessed at. (Subsurface is the big one: nothing carries.)

The material grid

material_grid.glb is the reference scene the matrix above is measured against: 9 material families × a 7-step parameter sweep, plus a textured row — 70 spheres.

It ships with the viewer. The installers put it next to the executable on Windows, macOS and Linux, so it is one File ▸ Open (Ctrl+O, or drag-and-drop on Windows and macOS) away — no build required. It is deliberately not the startup scene; the bundled helmet stays the default. If you want to see everything this renderer does in one screen, open the grid:

Platform Where it lands
Windows next to model_viewer_handle_vk_win.exe in the install dir
macOS inside the .app bundle, Contents/Resources/
Linux next to the binary (.deb / tarball)

In a source tree it is assets/material_grid.glb, and the build copies it next to the executable alongside sample.glb.

Row Family Sweep
0 dielectric roughness 0.03 → 1.0
1 metal roughness 0.03 → 1.0
2 clearcoat clearcoatFactor 0 → 1 over a rough red base
3 sheen sheenRoughnessFactor 0.05 → 1.0
4 anisotropy anisotropyStrength 0 → 1 on brushed metal
5 iridescence film thickness 200 → 800 nm
6 specular / IOR specularFactor 0 → 1, ior 1.0 → 2.0
7 transmission transmissionFactor 0 → 1, ior 1.5, volume
8 emissive emissiveStrength 0 → 6
9 textured one texture-driven property per column, all reading one ramp

coat_test.glb

assets/coat_test.glb is a second sweep, six rows by seven, for KHR_materials_coat — Khronos publishes no conformance asset for it, so this stands in. Every row shares one light neutral base; row 0 is plain KHR_materials_clearcoat and row 1 is the same material in coat's spelling.

Row Sweep
0 CONTROL — clearcoatFactor 0 → 1
1 coatFactor 0 → 1 (the same material, coat's spelling)
2 coatColorFactor white → amber, coat 0.5
3 coatDarkeningFactor 0 → 1, coat 0.5
4 coatIor 1.0 → 2.0 (f0 0 → 0.111)
5 coatAnisotropyStrength 0 → 1
6 coatNormalTexture ripple, coatFactor 0 → 1

Measure with python3 scripts/probe_coat_test.py <atlas.png>. Rows 2 and 3 run at coat 0.5 on purpose: at full coat the base is almost entirely displaced by the coat's own mirror reflection of the sky, and tint and darkening both act on the base, so there is nothing left for them to act on. The first cut of this asset used a red base at full coat and measured a flat row for both.

diffuse_fuzz_test.glb

assets/diffuse_fuzz_test.glb, six rows by seven, for KHR_materials_fuzz and KHR_materials_diffuse_roughness — again, Khronos publishes no conformance asset for either.

Row Sweep
0 CONTROL — KHR_materials_sheen, sheenColorFactor white → black
1 fuzzFactor 0 → 1, white fuzz
2 fuzzColorFactor white → black at weight 1
3 fuzzRoughnessFactor 0.05 → 1
4 diffuseRoughnessFactor 0 → 1, matte base
5 diffuseRoughnessFactor 0 → 1, semi-gloss base

Measure with python3 scripts/probe_fuzz_test.py <atlas.png>. Rows 0 and 2 are the same sweep under both extensions and must move in opposite directions — that divergence is the extension's reason to exist. Row 5 exists to catch coupling: diffuse roughness must not touch the specular lobe, and rows 4 and 5 responding differently is what shows it does not.

material_grid.glb is deliberately not extended when an extension lands. It is the baseline every "does this change the render" measurement is taken against, and an asset that moves each time cannot serve that purpose.

The grid is a progress meter as much as a test asset: a row is flat while its extension is ignored and comes alive when the extension lands. All nine rows now sweep, and the grid raises no ignored-extension warning at all. Anisotropy and iridescence sweep only faintly for the physical reasons documented above — measurable by pixel probe, easy to miss by eye. The transmission row is the clearest demonstration: the emissive spheres from the row below appear refracted inside each glass sphere, more strongly as transmissionFactor rises.

It is generated, not hand-authored, and the generator is the source of truth:

python3 scripts/make_material_grid.py          # → assets/material_grid.glb

A grid is a measuring instrument, so every value in it has to be inspectable and re-derivable — when a shader change moves a pixel, the question is always "what exactly is that sphere's roughness?", and a checked-in binary can't answer it. The script can, and it regenerates byte-identical output. Materials are named (04_anisotropy_0.50), so the glTF is self-documenting too.

The extensions are declared in extensionsUsed, never extensionsRequired, so a loader that implements none of them still opens the file — which is the point.

Row 9 embeds a single 8×64 PNG whose four channels all carry the same 0→1 ramp. The extensions read different channels (clear coat R, clearcoat roughness G, sheen roughness A, transmission R, thickness G…), so one image drives every one of them and each sphere shows a pole-to-pole sweep of its own property. If texture support regresses the row goes flat — the same tell the rest of the grid uses.

Environment and grading

Comparing a material against how it looked in the authoring tool only means something when the lighting and the grading are pinned and written down — otherwise every difference is attributable to the viewer's lighting rather than to the material. The viewer therefore makes all three explicit and reports them in the HUD, so a screenshot records the conditions it was taken under.

Environment. By default the IBL is baked from a procedural analytic sky (or from the analytic room, in room mode), so the viewer runs with no environment asset at all. Drop an equirectangular .hdr on the window (Windows/macOS; or Ctrl+O it anywhere, or ship one as environment.hdr next to the executable to have it load at startup) and the irradiance + prefiltered cubes are rebaked from it. Loading an HDRI also switches the analytic key light off — a real capture already contains its own sun, and keeping both would double-count the dominant light source.

Lighting mode. L cycles it; --env= sets it at launch. It is the answer to "why does the model change materials when the page hands it to the viewer":

Mode What it is
sky The analytic-sky IBL plus one key light — the viewer's own look. Grades at PBR Neutral, 0 EV: the Khronos glTF Sample Viewer's default, so assets match its reference render.
room The storefront page's default. three.js's RoomEnvironment — a box lit by one point light, six unlit furniture boxes and six emissive panels — which the page bakes to a PMREM and uses as its ONLY light source. This viewer reproduces it analytically (model_common/shaders/room.glsl) and feeds it through its own IBL bake, so the same panel reflections travel across a metal body. No punctual lights at all, no tone curve, 0 EV, matching the page's NoToneMapping default. Entering or leaving the mode rebakes the IBL cubes — a ~0.25 s hitch, once (the second containing it renders 46 frames instead of 61). In an opaque window the background becomes the room, blurred, because it is what the model reflects; transparent mode draws no background at all, exactly as before.
studio The three-point rig the DisplayXR storefront's inline-3D tile lights the same model with (three.js: key 2.2, fill 0.7, rim 1.0, plus a 0.6 hemisphere ambient), no tone curve, 0 EV — matching the page, which runs three.js at its NoToneMapping default. The sky IBL drops to a 0.15 residual, because the page has no environment map at all; not to 0, because a metal with no environment goes pure black wherever the three lights miss, and a black body carries no parallax detail for the panel to show.
none No lights; the IBL ambient only.

Metal is what this is for. Three sharp lights on an otherwise unlit body read as metal; a smooth sky reflection over the whole body reads as matte. On WaterBottle.glb, switching sky → studio drops the median luminance 162 → 60 while the 99th percentile holds (245 → 248), so the highlight-to-body contrast goes from 1.5× to 4.1×. room sits between the two (median 107, p99 250, contrast 2.3×): an environment, so the body is lit everywhere the way the sky lights it, but a STRUCTURED one, so the panels still read as reflections travelling across the metal rather than as a uniform wash.

The fill and rim lights take the base metallic-roughness lobe only — the KHR_materials_* layers (sheen, coat, fuzz, scatter) still see the key light alone. Those layers are dominated by one light, and the page-authored assets this rig exists to match declare none of them.

Anti-aliasing. The internal colour and depth targets are 4× MSAA, resolved into the single-sample image the per-eye blit reads (falls back to 2× or 1× if the device cannot do 4× for all three attachment formats). DXR_MODELVIEWER_MSAA=<1|2|4|8> overrides it, which is how a before/after is measured without a rebuild. Note the panel's own limit: the display processor hard-masks the silhouette alpha to 0/1, so a transparent window's outline against the desktop cannot be fully smooth — internal edges and texture detail are what improve.

Exposure. [ / ] in quarter stops; the shader multiplies linear radiance by 2^EV. Switching the lighting mode re-pins exposure and the tone curve to that mode's pairing (the grading is part of the mode, not an independent knob); [, ] and G after an L are deviations from the mode.

Tone curve. G cycles:

Curve Use
PBR Neutral (default) Khronos' glTF tone mapper. Preserves authored hue and saturation up to the compression knee — the curve to use for authoring-tool comparisons.
ACES Stephen Hill's RRT/ODT fit. Filmic; matches DCC viewports that default to ACES.
none (clamp) No curve, just clamp. Reproduces pre-#70 captures.

The same exposure and curve are applied to the model and the background, so the model never reads as pasted onto the environment. The background is deliberately sampled from a high roughness mip: a sharp, high-contrast sky sits far from the display's zero-disparity plane, where it causes lightfield cross-talk. A soft background stays comfortable.

Deterministic capture. Set DXR_MODELVIEWER_DETERMINISTIC=1 to pin the idle auto-orbit off at startup. Reference renders are only comparable if nothing moves between them, and the viewer starts slowly rotating the scene ~10 s after the last input — long enough that a scripted launch, wait, capture sequence lands at an unpredictable angle. Measured on the material grid, two captures 12 seconds apart:

channels differing
DXR_MODELVIEWER_DETERMINISTIC=1 0 of 1,382,400 (0.00 %)
default 1,347,650 (97.49 %)

An environment variable rather than a flag because the macOS build is an .app bundle with no argv, and a capture harness needs to set this the same way everywhere. Windows and macOS only — Linux has no auto-orbit.

Reference renders. scripts/capture_reference.sh <asset.glb> [outdir] captures the viewer's output and writes a sidecar recording the conditions it was taken under — asset, platform, viewer commit, active environment, and any extensions the asset declared that were ignored. The sidecar is scraped from the runtime's own log rather than restated, so it cannot drift from what the viewer actually did. A reference nobody can reproduce is a screenshot, not a reference; two independent runs of the script produce byte-identical PNGs.

None of this makes output identical across physical displays — panel calibration, brightness, gamut and 3D cross-talk are separate concerns.

Controls

Input Action
WASD / Q / E Strafe the virtual display in 3D
Left-click drag Rotate the virtual display
Scroll / trackpad Zoom (virtual display height)
- / = Decrease / increase depth + IPD together (10 %–100 %)
M Auto-orbit: slow turntable rotation when idle
V Cycle rendering modes advertised by the display runtime
Ctrl+O or top-bar Open… Load a different model (glTF / STL / OBJ / FBX / USD) — or an .hdr environment
Drag-and-drop (Windows, macOS) Load a supported model, or an .hdr environment, dropped onto the window
[ / ] Exposure down / up, in quarter stops
G Cycle the tone curve (PBR Neutral → ACES → none)
L Cycle the lighting mode (sky → studio → room → none) — see Environment and grading
Space Reset pose, zoom, depth
Tab Toggle HUD
Ctrl+T Toggle transparent background (desktop see-through; Windows only)
Esc Quit

Undocking the viewer (launch flags + displayxr-view: links)

The viewer can be started by a web page, by a CAD app, or from a shell tile as a floating 3D window over the desktop showing a given asset at a given screen rect. Windows and Linux (see "On Linux" below); shared with the sibling splat viewer through displayxr-common so both speak one grammar and apply one security policy.

Command line

model_viewer_handle_vk_win.exe [flags] [model-path]
Flag Meaning
--transparent Borderless, topmost and click-through-shaped from the first frame — no style flip after the window exists. Ctrl+T still toggles it live.
--rect=X,Y,W,H Window rect in physical virtual-screen pixels. The exe is PerMonitorV2, so no DPI scaling is applied to what you pass. With --transparent the rect is the window rect exactly; framed, it is the client area.
--src=<url|path> Asset to load instead of the bundled sample. A URL is downloaded to the cache first (progress shows as a toast). A .gltf also has its buffers[]/images[] fetched relative to its URL — see Multi-file glTF.
--vh=<metres> Virtual display height the asset was authored at. Pins the scale: auto-fit will not re-derive it.
--pose=YAW,PITCH[,ZOOM] Orbit the sender was showing the asset at, in the page's degrees (the inline3d SDK's setPose({yaw, pitch, zoom}); zoom multiplies the fit, default 1). Applied once per load, right after the fit frames the model, so an undocked product opens on the same face its tile was showing instead of snapping face-on. Yaw is normalised to (-180, 180]; pitch is clamped to the orbit's own limit. Auto-orbit is not re-armed — see below.
--margin=<0..1> Fraction of the window the framed asset may fill — the sender's own fit margin. Replaces the built-in 80%. --vh still outranks it: a margin tunes the guess, a vh pin replaces it.
--title=<suffix> Appended to the window title, never replaces it.
--type=model|splat Routing hint. A non-model type is forwarded to the sibling viewer.
--env=room|studio|sky|none Lighting the sender rendered with, so the undocked view matches the tile it came out of. Protocol launches default to room (a protocol launch is an undock from a storefront page, and room is that page's SDK default — a page that lights with the studio rig says env=studio explicitly); a plain command-line launch defaults to sky. An unrecognised value warns and is ignored. L cycles it live.
--dpr=<float> The launching page's devicePixelRatio. Logged only — a calibration aid.
--max-bytes=<n> Download cap (default 256 MiB).
--no-cache Re-download even on a cache hit (dev aid).
--allow-local Native callers only: permit a local/file: src inside a protocol URL. A web page can never set it — it is an argv flag.

Flags are --key=value (never --key value). The first non-flag token is still the legacy positional model path, so every existing launcher keeps working. Exit codes: 2 = the launch was refused (bad or disallowed arguments), 3 = the link belongs to a sibling viewer that is not installed.

The opening pose is held, not spun — and that is a deliberate difference from the page. The storefront's tile passes a non-zero idleSpin to the SDK, so a product starts turning a few seconds after it appears. The undocked view does not: in transparent mode with a standalone session the idle turntable is suppressed outright, so the window holds --pose until the user drags it. Applying a launch pose does not re-arm auto-orbit — the pose is staged as the framed pose (the same slot the auto-fit yaw has always used), not as a user input, so it neither restarts the idle countdown nor flips the M state. Space returns to it, exactly as it returns to the framed yaw.

On Linux (model_viewer_handle_vk_linux) --transparent, --rect, --pose, --margin, --src and --title follow the same rules. --rect is the CONTENT rect in desktop device pixels: X root coordinates on X11; on native Wayland the runtime's device convention (a monitor's logical origin times its scale, plus the monitor-relative logical offset times the same scale — what the window-geometry feed reports). It is placed by displayxr-common's DxrLinuxWindow::request_initial_rect: on X11 the window is created there; on Wayland, where a client cannot place itself, the surface is sized at the target monitor's scale and moved through the window-geometry@displayxr.org GNOME extension once its first frame is presented. Expect up to 1 px of rounding at a fractional scale, or under XWayland at a scaled desktop. A --rect window is always windowed, even at the panel's size. --src=<url> downloads exactly as on Windows (same policy, redirect re-check, cap, timeouts and SHA-1-named cache files) into $XDG_CACHE_HOME/displayxr/<viewer> (~/.cache/... by default), through libcurl loaded at run time — the .deb Recommends curl, which provides it; without it a URL reports "no HTTP library" and local paths work as before. --title is appended to the window title. With no toast layer on Linux, the download progress and any error go to the log.

A refused or forwarded launch also raises a message box, because a protocol launch has no console and without one a rejected link is indistinguishable from a crash. Set DXR_LAUNCH_QUIET=1 to suppress every one of those dialogs — the log line and the exit code, which the box only ever restated, are unchanged. Automated checks and CI should always set it: the box is modal and would otherwise sit on the display until a human clicks OK, long after the check that raised it has exited. DXR_LAUNCH_QUIET=0 disarms the override.

set DXR_LAUNCH_QUIET=1
model_viewer_handle_vk_win.exe "displayxr-view://open?src=...&v=1"

The displayxr-view: protocol

The viewer registers HKCU\Software\Classes\displayxr-view on every launch (the installer runs elevated, so an HKCU write from there would land in the wrong hive; self-registration is also self-healing after a sibling is uninstalled). A page opens the viewer with:

displayxr-view://open?src=<pct>&type=model|splat&rect=X,Y,W,H&vh=0.2&dpr=2.5&title=<pct>&env=room&pose=-40,10&margin=0.8&transparent=1&v=1

open is the verb; v=1 is the grammar version, so a future grammar is rejected loudly instead of half-honoured. Every value is percent-encoded by the sender (encodeURIComponent); + is not a space. A protocol launch is transparent by default (displayxr-common >= v2.9.1) — undocking into a floating overlay is what the scheme exists for; transparent=0 opts out for a framed, positionable window. On the command line --transparent stays opt-in. It also defaults to env=room, so an undocked model keeps the lighting the page rendered it with; send env=studio, env=sky or env=none to override.

One scheme serves every DisplayXR viewer, so the browser asks the user once. A URL whose type= is not model is handed to the sibling viewer found at HKCU then HKLM\Software\DisplayXR\Demos\<Key>\InstallPath (the per-user key is a dev override, same precedence as the workspace manifests); if it is not installed, the viewer says so and exits. A second launch while one is already running does not open a second window — the URL is handed to the running instance over WM_COPYDATA, which moves it to the new rect and loads the new asset.

Security policy

The browser's one-time "Open this app?" dialog is the only consent gate and it is sticky per origin, so from a protocol launch src must be https: (any host) or http: on loopback, and file:, UNC and bare local paths are refused — a protocol-launched viewer that opened arbitrary local files would be a file-existence oracle through its own error toasts, reachable from any page on an allowed origin. The same policy is re-applied to the final URL after redirects, lengths and rects are bounded, and the cache file's extension comes from the URL path, the Content-Type or the magic bytes — never from a query parameter.

Download cache

Downloads land in %LOCALAPPDATA%\DisplayXR\ModelViewer\cache, named by the SHA-1 of the requested URL (never by anything in the URL's path, so there is no traversal surface). A cache hit skips the network entirely, which is what makes the second undock of the same asset instant; --no-cache bypasses it. Deleting the directory is always safe.

Multi-file glTF (.gltf + .bin + textures)

A .gltf is JSON that points at its payload: buffers[].uri and images[].uri are paths relative to the .gltf itself. So a src= that names a .gltf is a manifest, not an asset — the viewer downloads it, reads those URIs, and fetches each one relative to the .gltf's final URL before loading anything. .glb (self-contained) and every other single-file format are untouched by this.

The siblings land in a per-asset directory …\cache\<sha1-of-the-url>\, each at the relative path the JSON spells (textures/albedo.jpg → <sha1>\textures\albedo.jpg), with the .gltf copied in beside them under its own file name. The tree on disk therefore mirrors the server's and the loader resolves it with no rewriting. A directory that already holds the .gltf and every file it references is a cache hit — no network at all. Progress shows as a Downloading 3/7… toast; the --max-bytes cap applies to the sum, and any sibling that fails aborts the load rather than showing a half asset.

Same-origin rule. A sibling must be a plain relative reference and must resolve to the same scheme + host + port as the .gltf — before and after redirects. Refused outright: anything with a scheme (https:, data: payloads are simply skipped as already-embedded, and a C: drive letter parses as a scheme too), a leading / or //, any .. segment raw or percent-encoded (%2e%2e), backslashes, %2f, control characters, a query or fragment, and any extension outside {.bin .png .jpg .jpeg .webp .ktx2 .basis}. A downloaded JSON document is untrusted input, and this is the only thing standing between it and a filesystem write, so it is an allowlist and it is unit-tested directly: ctest --test-dir build -R gltf_uri_tests (source: model_common/tests/gltf_uri_tests.cpp).

Build from source

Prerequisites (both platforms)

model_common/ fetches tinygltf, glm, and tinyusdz (USD) via CMake FetchContent on first configure (no submodules); tinyobjloader (OBJ) and ufbx (FBX) are vendored under model_common/third_party/. STL has no dependency. The first configure builds tinyusdz from source, so it is slower.

macOS

brew install cmake ninja vulkan-sdk openxr-loader
./scripts/build_macos.sh
# Run against an installed DisplayXR runtime (handles the Vulkan-loader setup):
./scripts/run_macos_dev.sh

Launch the dev build with scripts/run_macos_dev.sh, not the bare binary. The dev binary links Homebrew's Vulkan loader while the installed runtime loads its own; the script converges both on one loader (and points Vulkan at the runtime's bundled MoltenVK) so the xrGetVulkanGraphicsDeviceKHR handshake succeeds. The distributed .app (build_macos.sh --installer) bundles a self-consistent Vulkan stack and needs none of this.

Windows

REM Sets vcvars64 + OpenXR_ROOT + Vulkan SDK, then configures + builds.
scripts\build-with-deps.bat
REM Run
build\windows\model_viewer_handle_vk_win.exe

Use build-with-deps.bat, not the bare build_windows.bat — the latter assumes you are already inside a VS developer environment.

Repo layout

.
├── macos/                  Platform entry + window handling (Cocoa / MoltenVK)
├── windows/                Platform entry + window handling (Win32 / Vulkan)
├── model_common/           Multi-format PBR renderer: loaders + renderer + shaders
├── common/                 Shared helpers: Kooima math, input, HUD
├── openxr_includes/         Vendored OpenXR headers (incl. DisplayXR extensions)
├── installer/              Windows NSIS + macOS .pkg installers
├── scripts/                Build scripts for each platform
└── PORTING.md              Roadmap (port done; animation/skinning next)

common/ and openxr_includes/ are shared with the other DisplayXR demos and were seeded from the runtime source tree.

Why a glTF viewer (not a Gaussian-splat fork)

This is a separate demo, not a mode bolted onto the splat viewer: it shows a different DisplayXR capability (mesh + PBR rendering) and grows the demo gallery. The renderer draws on techniques from the MIT-licensed SaschaWillems/Vulkan-glTF-PBR.

License

Apache-2.0 — see LICENSE. Bundled demo models carry their own licenses. (Vendored OpenXR extension headers under openxr_includes/ remain BSL-1.0 — see their SPDX headers.)

About

DisplayXR demo — glasses-free 3D glTF 2.0 PBR model viewer (OpenXR + Vulkan, Windows & macOS).

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages