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 thedisplayxr-runtimereleases page:DisplayXRSetup-*.exeon Windows orDisplayXR-Installer-*.pkgon 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.
| 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.
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 |
|
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) |
diffuseRoughnessTexture (R) — Fujii energy-preserving Oren-Nayar for direct light; IBL uses the spec's normal-bend approximation |
KHR_materials_fuzz (draft) |
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) |
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:
coatDarkeningFactordefaults 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. WithKHR_materials_coatpresent the spec's 1.0 applies.- Darkening is gated by coat weight. The spec's composition applies
coatColor × coatDarkeningto the base outside the weighted mix, so a material withcoatFactor: 0would 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 isF_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=1restores 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.)
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 |
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.
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.glbA 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.
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.
| 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 |
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.
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 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.
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.
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.
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).
- CMake ≥ 3.21 + Ninja
- Vulkan SDK (includes
glslangValidator) - OpenXR loader (find_package-visible)
- A DisplayXR-compatible runtime (install via
DisplayXRSetup-*.exefrom displayxr-runtime releases)
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.
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.shLaunch 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 thexrGetVulkanGraphicsDeviceKHRhandshake succeeds. The distributed.app(build_macos.sh --installer) bundles a self-consistent Vulkan stack and needs none of this.
REM Sets vcvars64 + OpenXR_ROOT + Vulkan SDK, then configures + builds.
scripts\build-with-deps.bat
REM Run
build\windows\model_viewer_handle_vk_win.exeUse
build-with-deps.bat, not the barebuild_windows.bat— the latter assumes you are already inside a VS developer environment.
.
├── 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.
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.
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.)