Skip to content

feat(layers): composition layer support behind enableLayers() - #516

Open
salmanmkc wants to merge 14 commits into
google:mainfrom
salmanmkc:feat-layers-split
Open

feat(layers): composition layer support behind enableLayers()#516
salmanmkc wants to merge 14 commits into
google:mainfrom
salmanmkc:feat-layers-split

Conversation

@salmanmkc

@salmanmkc salmanmkc commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Description

I added webgl support so this should work on android xr too, I tested that on meta quest, though not sure if dimensions need adjusting on android xr and if it works fully there. The demo shows switching between layer on and off. Webgl looks the exact same on my meta quest.

layer.demo.mp4

Anything drawn into the eye buffer is resampled at least twice before you see it: once into the buffer, which is already coarser than the panel across the area a given object covers, and again when the compositor warps it to head pose. A quad composition layer skips both and is sampled once, at its own resolution, straight by the compositor. That is a property of the render path rather than of any one headset.

The cost is easiest to see with numbers. A 1.2m quad two metres away covers about a third of the field of view, so on a Quest 3 it lands on roughly 620 eye buffer pixels across. Feed that a 2048 wide source and around 70% of the detail is gone before the compositor has even seen the frame. Fine text and single pixel lines are where it shows.

Adds LayerManager, LayerCapability and VideoLayer under src/layers/, plus a demo in demos/layers/. All behind options.enableLayers(), off by default. Split into seven commits since it introduces a subsystem, and each one stands on its own.

Both backings are implemented, because neither one covers the devices we care about on its own. XRMediaBinding is the cheap path, where the compositor drives the video element and the app never draws a frame, but Quest is the only browser that ships it. Chrome has quad layers only through XRWebGLBinding, where the app uploads each frame itself, so that is the path Android XR needs.

LayerManager exists because three.js sets layers: [projectionLayer] once at session start and never touches that array again. Anything extra has to be composed back in together with the projection layer, and dropping it blanks the scene, so setBaseLayer is required before anything can be added.

VideoLayer reports whether it got a layer instead of throwing, so an app can ask for one everywhere and just draw into the scene where there isn't one. It falls back separately for no session, no base layer, no binding, and a platform that advertises a binding but refuses a particular element.

The quad sizing is worth calling out, because the two paths deliberately differ and it looks like a bug otherwise. The spec means width and height as full metres, and Chromium passes them to OpenXR unchanged, but the Quest compositor treats them as half extents and returns a quad at twice the size. It is a property of the compositor rather than of one binding, so the correction is decided by the platform, not the path. This is immersive-web/layers#324, still open. Worth knowing that three.js and A-Frame both hardcode a /2 for Quest, which means copying either of them renders the quad at half size on Android XR.

A few other things the spec is explicit about and the official samples get wrong or omit: layout has to be named because 'default' is a TypeError for quad layers, isStatic has to be false or getSubImage raises InvalidStateError once needsRedraw clears, and getSubImage throws until updateRenderState has taken effect a frame or two after the layer is added, so a failed upload drops that frame rather than tearing the layer down.

Two faults only showed up on device. The layer texture is allocated once at the size given at creation, and the test card is a canvas stream that reports no dimensions for its first frames, so guessing a size and then uploading a differently sized frame raised a GL error every frame and took the whole session down. And uploading through raw gl.bindTexture left three.js's state cache describing something untrue, so it carried on rendering with the compositor's texture bound. Uploads are also skipped for frames the video has not advanced past, since the source runs at 30fps against a 90fps render loop.

The demo puts the same generated card up twice, once as a layer and once as a plane in the scene, so the only variable is the path the frame takes. It is generated rather than a video file because the sample clips I tried are lower resolution than the eye buffer, so both sides looked identical and it showed nothing. The two sides swap on a timer, which matters: lenses are sharpest in the middle and you read one panel at a time, so a fixed left/right layout cannot separate "the layer is sharper" from "that side was in the sweet spot". Swapping does separate them, and on device the difference follows the layer across the swap. ?webglLayer=1 forces the WebGL path on a platform that has both, which is what lets a Quest stand in for Android XR.

Type of Change

  • Bug fix
  • New feature / enhancement
  • New demo or sample
  • Documentation update

Media / Screen Recordings & Screenshots (If Applicable)

  • Simulator Recording: the simulator has no compositor, so both sides fall back to scene textures and there is nothing to compare. The readout says so rather than leaving a blank space.
  • Device Recording: filmed through the lens on a Quest 3 (top of pr)

Checklist

  • Tested in simulator & device: Quest 3 for both paths, desktop simulator for the fallback. 43 unit tests, and every commit passes them standalone. The WebGL path has not been run on Android XR hardware, only on a Quest with the media path forced off, so the full extent behaviour there is reasoned from the Chromium source rather than measured.
  • Large Assets ($\ge$ 1MB): None, the test card is generated at runtime onto a canvas.
  • SDK Dynamic Dependencies: None added.
  • Security: No keys or secrets.

Layers are optional in two independent places, and a session existing
says nothing about either: the session may not have been granted the
layers feature, and the binding may not offer the layer type wanted.

The two backings are worth telling apart rather than reducing to a
boolean. XRMediaBinding lets the compositor drive a video element with
the app never drawing a frame for it, which is the cheap path, but Quest
is the only browser that ships it. Chrome has quad layers only through
XRWebGLBinding, where the app uploads each frame itself. Anything that
wants to run in both places has to know which one it is on.

preferWebGL exists because Quest has both and would otherwise always
take the media path, leaving the WebGL path with no hardware to run on.
three.js sets layers to a single projection layer once when the session
starts and never touches that array again. Its later updateRenderState
calls only carry depthNear and depthFar, which merge, so adding to the
array is safe, but the array has to be rebuilt in full every time and
leaving the projection layer out of it blanks the scene.

That is a sharp edge to hand to callers, so the manager owns it: nothing
can be added before setBaseLayer has been told what three.js is
rendering into, and every submission puts that layer first. Ordering
follows the spec, earlier entries composite behind later ones, so app
layers sit in front of the scene.

The session, binding and context are held together because the WebGL
path needs all three to upload a frame, and dropped together when the
session ends so nothing outlives it.
Quad layers need the layers feature named when the session is requested.
It cannot be turned on afterwards, so a session that did not ask for it
can never have one, and createQuadLayer throws NotSupportedError rather
than degrading.

It goes in as an optional feature, not a required one, so asking for
layers on a platform without them still gives a working session that
falls back to drawing into the scene. Off by default, since it costs a
feature request that most apps have no use for.
Video is the case where going through the eye buffer costs the most. A
1.2m quad two metres away covers roughly a third of the field of view,
so on a Quest 3 panel it lands on about 620 pixels across. Hand it a
2048 wide source and around 70 percent of the detail is gone before the
compositor ever sees the frame, and what survives gets resampled a
second time when it warps to the head pose. As a composition layer it is
sampled once, at its own resolution.

This is the XRMediaBinding path, where the compositor drives the video
element and the app never draws a frame for it.

VideoLayer reports whether it got a layer rather than throwing when it
cannot, so an app can ask for one everywhere and draw the video into the
scene on platforms that have none. Each way it can fail falls back
separately: no session, no base layer to compose against, no media
binding, and a platform that advertises the binding but still refuses a
particular element, which happens with one whose metadata has not
loaded yet.

Quad extents need halving here and it looks like a bug without the
reason. The spec means width and height as full metres and Chromium
passes them to OpenXR unchanged, but the Quest compositor treats them as
half extents and returns a quad at twice the size. It is a property of
the compositor rather than of this binding, so it is decided by the
platform. See immersive-web/layers#324, still open.

Height comes from the video's aspect ratio unless given, defaulting to
16:9 while metadata is still loading rather than dividing by a zero
videoWidth.
Chrome does not implement XRMediaBinding and is not going to, so every
Android XR device falls back to drawing the video into the scene, which
is the case this whole thing exists to avoid. This adds the path Chrome
does have: create the quad through XRWebGLBinding and upload each frame
into its texture with texSubImage2D.

The layer texture is allocated once, at the size given when it is
created, and texSubImage2D refuses a source that does not fit it.
Guessing a size and later uploading a frame of a different one raises a
GL error on every frame, which on a Quest took the whole session down,
so it waits for the video to report real dimensions instead.

Binding a texture directly also has to be undone. three.js caches GL
state and skips calls it believes are redundant, so leaving the layer
texture bound left its cache describing something untrue and it went on
to render with the wrong texture. The active unit, the 2D binding and
the flip flag are all put back.

Uploads happen only for frames the video has actually advanced past. The
source runs at 30fps and the headset renders at 90, so two in three were
pushing an identical 2048x1152 image, which is about 850MB/s of texture
traffic for nothing. needsRedraw still forces one, since that means the
compositor lost the layer contents.

Layers are created with isStatic false, because getSubImage raises
InvalidStateError on a static layer once needsRedraw has gone false and
a video needs a new frame every time. Layout is named explicitly since
'default' is a TypeError for quad layers. getSubImage also throws until
updateRenderState has taken effect, a frame or two after the layer is
added, so a failed upload drops that frame rather than tearing the layer
down.
The first attempt at this demo used a sample video off a CDN. Both
copies looked identical, which was not the comparison working, it was
the source being lower resolution than the eye buffer already: there was
nothing left for the layer path to preserve.

So the card is generated instead, at 2048x1152 onto a canvas and fed to
both sides through captureStream. Text runs from 96px down to 12px and
there are one, two and three pixel line pairs, which is where resampling
shows first and where the eye buffer path visibly loses.

Being generated also means no asset to host, and the resolution can be
changed to find where the difference stops mattering.
Puts the same generated card up twice, once as a composition layer and
once as an ordinary textured plane in the scene, so the only variable is
the path the frame takes to the display.

The two sides swap on a timer, which is load bearing rather than a
gimmick. Lenses are sharpest in the middle and you read one panel at a
time, so a fixed left and right layout cannot separate "the layer is
sharper" from "that side happened to be in the sweet spot". Swapping
does separate them: a difference that follows the path is real, one that
stays on a side is the optics.

Select swaps early and deliberately does not stop the timer. Squeeze is
bound to select end, so a grip press while holding the controller ended
the comparison for good, with nothing on screen to say why.

The readout says which binding was asked for, which one is actually
running and how many frames have been uploaded, because a line that only
says "media" cannot distinguish a request that was ignored from one that
was never made. It renders to the page and into the session both, since
a DOM overlay disappears the moment you enter XR.

?webglLayer=1 forces the WebGL path on a platform that has both, which
is what lets a Quest stand in for Android XR.
Wrapped returns and argument lists the formatter wanted broken
differently. No behaviour change.
@dli7319
dli7319 self-requested a review August 13, 2026 00:47
@dli7319

dli7319 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Both sides are looking the same to me on Galaxy XR. Hmm.
Maybe composition layers is behind a flag or something in Chrome.
I need to ask around.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants