Skip to content

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

Merged
dli7319 merged 31 commits into
google:mainfrom
salmanmkc:feat-layers-split
Sep 20, 2026
Merged

dli7319 merged 31 commits into
google:mainfrom
salmanmkc:feat-layers-split

Conversation

@salmanmkc

@salmanmkc salmanmkc commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Description

The device recording below was made on a Quest 3 against an earlier SDK build, with both the media and forced WebGL paths tested. The current demo has not been re-validated on a headset. Android XR hardware remains untested, including the dimensions and full behaviour of its WebGL path.

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.

After the spatial UI migration, the demo still called SpatialPanel, which no longer exists. That throws during script init, so the script never activates or reaches layer attachment. Replaces only the readout with UICard and UIText, plus the importmap entries that UI needs. The demo still creates its own manager; layer creation, video uploads and startup order are unchanged.

The same real Chromium check fails on b0e15e19 with SpatialPanel is not a constructor and the script in failedScripts. After the readout fix, the script is active with no init exception. The card is constructed and mounted in the scene graph with the expected UIText values, both video meshes play, swaps update positions and text values, and the original Object3D.layers mask stays intact. This browser takes the unsupported-layer fallback path, not native composition layers.

Visible glyph rasterization could not be confirmed under headless SwiftShader. A separate control reproduces this pre-existing rasterizer limitation with an unmodified minimal public UICard: the same heading rectangle measures 35 bright pixels under SwiftShader versus 854 under real Metal. Headed SwiftShader also gives 35. The regression evidence here confirms initialization, scene-graph mounting and text values, not that the text was visibly rasterized.

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 against an earlier SDK build (top of pr). The readout fix has not been retested on a headset.

Checklist

  • Tested in simulator & device: Earlier SDK build tested on Quest 3 for both paths and in the desktop simulator for fallback. The current readout fix has real Chromium red/green initialization and scene-graph coverage, but no headset retest or visual glyph confirmation under SwiftShader. SDK build, lint and scoped Prettier pass; the full suite has 1,944 passed and one skipped. The 43 existing layer tests are preservation coverage, not new readout regressions. Android XR hardware remains untested.
  • Large Assets ($\ge$ 1MB): None, the test card is generated at runtime onto a canvas.
  • SDK Dynamic Dependencies: No SDK dependencies added. The demo importmap now includes the existing dependencies required by its replacement UI.
  • 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.

@salmanmkc

Copy link
Copy Markdown
Contributor Author

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.

ah, thanks for testing. it might be falling back to a normal scene texture on both sides, which would explain why they look the same. does the in-headset readout show webgl | attached: true with uploads increasing, or none | attached: false?

@dli7319

dli7319 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

I plan to submit this but I probably won't have time to figure out why it doesn't work on Galaxy XR.
I'll submit it once I test it on Quest 3.

@salmanmkc

Copy link
Copy Markdown
Contributor Author

I plan to submit this but I probably won't have time to figure out why it doesn't work on Galaxy XR. I'll submit it once I test it on Quest 3.

cool thanks! once Aura releases later this year I'll probably see if I can figure it out

@salmanmkc
salmanmkc marked this pull request as draft September 15, 2026 22:58
@salmanmkc
salmanmkc marked this pull request as ready for review September 15, 2026 23:13
SpatialPanel was removed in e3a53dc on 2026-08-04, but the demo still constructed it. Initialization threw, leaving the script in failedScripts without its composition-layer callbacks.

Use UICard and UIText for the readout and add only their required import-map mappings. Keep the existing manager, layer lifecycle and startup order.
@dli7319

dli7319 commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

I loaded it up on quest. The layers part is working but the page crashes after a minute or two.

Screenshot 2026-09-19 at 9 43 02 PM

@dli7319

dli7319 commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Longer trace:

Screenshot 2026-09-19 at 9 49 12 PM [Trace-20260919T214539.json.gz](https://github.com/user-attachments/files/32429417/Trace-20260919T214539.json.gz)

Comment thread demos/layers/LayersScene.js
Comment thread src/layers/VideoLayer.ts Outdated
@dli7319

dli7319 commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Also my agy says we shouldn't keep repeatedly calling createQuadLayer.

Share one drawing loop across both video consumers and update quad transforms instead of recreating native layers. Bound ready-source attachment attempts, preserve placement across sessions, and release resources on teardown and rejected submissions.

Add regressions for producer lifetime, stable layer identity, fallback, retries, session re-entry, and cleanup errors.
Use the XRMediaBinding and XRMediaQuadLayerInit declarations already provided by @types/webxr instead of maintaining a handwritten constructor shape. Keep runtime availability guards for browsers without media layers.
Load three.js 0.186.0 to match the SDK and avoid disposal failures from the older Object3D implementation. Check all three import-map entries against the SDK peer requirement.
@salmanmkc

salmanmkc commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor Author

thanks for the Quest report and trace. fixed the duplicate drawing loop and changed the swaps to update the existing quad transform instead of calling createQuadLayer again every three seconds. also bounded attachment attempts and cleaned up streams and layers on teardown/failure (165ce63).

084ee36 also aligns the demo with three.js 0.186.0. the old pin caused a reproducible cleanup error in the browser.

i have been testing the updated preview on my Quest 3 for a couple of minutes without a crash so far. encouraging, but not claiming a confirmed long-run fix or that both the media and forced-WebGL paths have been retested yet. -

Comment thread demos/layers/index.html Outdated
"@pmndrs/msdfonts": "https://cdn.jsdelivr.net/npm/@pmndrs/msdfonts@1.0.64/dist/index.min.js",
"@preact/signals-core": "https://cdn.jsdelivr.net/npm/@preact/signals-core@1.14.0/dist/signals-core.mjs",
"yoga-layout/load": "https://cdn.jsdelivr.net/npm/yoga-layout@3.2.1/dist/src/load.js",
"troika-three-text": "https://cdn.jsdelivr.net/gh/protectwise/troika@028b81cf308f0f22e5aa8e78196be56ec1997af5/packages/troika-three-text/src/index.js",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remove the troika imports including bidi-js and webgl-sdf-generator?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll just push it myself since it's trivial

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool thanks!

@dli7319

dli7319 commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Just retested and it works fine now after 2 minutes. Thank you

Still the same interesting memory graph on Quest tho.

@dli7319
dli7319 merged commit 428fd5d into google:main Sep 20, 2026
11 checks passed
@salmanmkc

Copy link
Copy Markdown
Contributor Author

Just retested and it works fine now after 2 minutes. Thank you

Still the same interesting memory graph on Quest tho.

I'll try look into the graph a bit more, thanks!

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