From 811f8d2458844222504501f550602c01cd6b26cb Mon Sep 17 00:00:00 2001 From: Hesperus Date: Tue, 25 Aug 2026 10:46:01 -0700 Subject: [PATCH 1/3] Dawn compat: dispatch the fence work-done callback by overload, not macros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer emdawnwebgpu packages add a WGPUStringView message parameter to WGPUQueueWorkDoneCallback (4 params: status, message, userdata1, userdata2); earlier packages use 3 (no message). The signature is a property of the Dawn package each emsdk release pins, so no clean version macro exists — and macro PRESENCE does not discriminate: both generations define WGPU_STRLEN/WGPUStringView (verified against Dawn v20260423.175430 [4-param] and the earlier package in this fork's deploy toolchain [3-param]). So: provide both signatures as overloads of _fence_work_done_callback and let the assignment to the port's own WGPUQueueWorkDoneCallback typedef select the matching one. The 4-param overload is additionally guarded on WGPU_STRING_VIEW_INIT for hypothetical pre-StringView headers. The dispatch pattern compiles against both package generations (verified standalone on both toolchains); a full engine build is verified on the 3-param toolchain. Co-Authored-By: Claude Fable 5 --- .../webgpu/rendering_device_driver_webgpu.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/drivers/webgpu/rendering_device_driver_webgpu.cpp b/drivers/webgpu/rendering_device_driver_webgpu.cpp index 722a25559c21..c78f18dcfd0e 100644 --- a/drivers/webgpu/rendering_device_driver_webgpu.cpp +++ b/drivers/webgpu/rendering_device_driver_webgpu.cpp @@ -63,7 +63,12 @@ static void _timestamp_readback_callback(WGPUMapAsyncStatus p_status, WGPUStringView p_message, void *p_userdata1, void *p_userdata2); // Fence work-done callback: fires when wgpuQueueSubmit work completes on GPU. -static void _fence_work_done_callback(WGPUQueueWorkDoneStatus p_status, void *p_userdata1, void *p_userdata2) { +// Newer emdawnwebgpu packages add a WGPUStringView message parameter to +// WGPUQueueWorkDoneCallback (4 params); earlier ones have 3. Macro presence +// does NOT discriminate the two (both define WGPU_STRLEN), so provide both +// signatures as overloads and let the assignment to the port's own +// WGPUQueueWorkDoneCallback typedef select the matching one. +static void _fence_work_done_body(void *p_userdata1) { WGFence *fence = (WGFence *)p_userdata1; if (!fence) { return; @@ -80,6 +85,14 @@ static void _fence_work_done_callback(WGPUQueueWorkDoneStatus p_status, void *p_ fence->signaled = true; } +[[maybe_unused]] static void _fence_work_done_callback(WGPUQueueWorkDoneStatus p_status, void *p_userdata1, void *p_userdata2) { + _fence_work_done_body(p_userdata1); +} + +[[maybe_unused]] static void _fence_work_done_callback(WGPUQueueWorkDoneStatus p_status, WGPUStringView p_message, void *p_userdata1, void *p_userdata2) { + _fence_work_done_body(p_userdata1); +} + // Parse "@group(G[u]) @binding(B[u])" from a WGSL string. // Handles the optional 'u'/'i' suffix that Tint emits for integer literals. // Returns true and sets r_grp/r_bnd on success. @@ -2800,6 +2813,10 @@ Error RenderingDeviceDriverWebGPU::command_queue_execute_and_present(CommandQueu fence->work_done_pending = true; WGPUQueueWorkDoneCallbackInfo cb = {}; cb.mode = WGPUCallbackMode_AllowSpontaneous; + // Overload set (see definitions near the top of this file): the + // port's WGPUQueueWorkDoneCallback typedef selects the matching + // signature. A "no matching conversion" error here means a new + // emdawnwebgpu changed the callback shape again. cb.callback = _fence_work_done_callback; cb.userdata1 = fence; cb.userdata2 = nullptr; From 31eae5aef2bc54a0fe0d0068d69ed39ca772079d Mon Sep 17 00:00:00 2001 From: Hesperus Date: Tue, 25 Aug 2026 10:28:36 -0700 Subject: [PATCH 2/3] tint: extend kAllowStructMemberSizeMismatch to the struct total-size check Vendored as patches/0007 per the thirdparty/tint/patches/ convention. This is a Group B (Specialization Constants) patch, completing the capability this fork introduced in 0002 and extended in 0003/0005: Godot's specialization constants change effective struct and array sizes at runtime, so a spec-constant-sized array leaves the containing struct's Size() at its unfolded value and the validator's TOTAL-size check (str->Size() < cur_offset) fires on IR that the fork's own 0002 rationale already classifies as valid-by-construction. 0002 relaxed the per-MEMBER size check for this exact producer; this patch applies the same capability to the struct-total check a few lines below it. Without this, the Mobile renderer's scene shader (depth shadow atlas + sampler UBO with spec-constant-sized arrays) fails Tint validation and every lit 3D scene renders black on Dawn/Windows, including this fork's own sample projects. Unshaded scenes are unaffected, which is why the gap survived: the fork's Windows TODO note and this failure are the same bug. Layer choice: an alternative would be changing Godot's shader generation so emitted SPIR-V never carries the mismatch, touching no vendored code. This fork already faced that choice for the member-size case and chose validator relaxation, three times (0002, 0003, 0005, documented as Group B in patches/README.md); this patch follows the established architecture rather than opening a second front. If Group B is ever re-architected toward shader-gen fixes, this check belongs in that migration with the rest of the group. Co-Authored-By: Claude Fable 5 --- ...d-size-mismatch-to-struct-total-size.patch | 21 +++++++++++++++++++ thirdparty/tint/patches/README.md | 1 + .../tint/src/tint/lang/core/ir/validator.cc | 10 ++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 thirdparty/tint/patches/0007-extend-size-mismatch-to-struct-total-size.patch diff --git a/thirdparty/tint/patches/0007-extend-size-mismatch-to-struct-total-size.patch b/thirdparty/tint/patches/0007-extend-size-mismatch-to-struct-total-size.patch new file mode 100644 index 000000000000..3f8fc3828bb1 --- /dev/null +++ b/thirdparty/tint/patches/0007-extend-size-mismatch-to-struct-total-size.patch @@ -0,0 +1,21 @@ +diff --git a/thirdparty/tint/src/tint/lang/core/ir/validator.cc b/thirdparty/tint/src/tint/lang/core/ir/validator.cc +index a0dfe1e..451c918 100644 +--- a/thirdparty/tint/src/tint/lang/core/ir/validator.cc ++++ b/thirdparty/tint/src/tint/lang/core/ir/validator.cc +@@ -2257,7 +2257,15 @@ void Validator::CheckType(const core::type::Type* root, + + cur_offset += (member->Offset() - cur_offset) + member->MinimumRequiredSize(); + } +- if (str->Size() < cur_offset) { ++ // GODOT PATCH (patches/0007, extends patches/0002): the total-size ++ // check must honor kAllowStructMemberSizeMismatch like the ++ // member-size check above — spec-constant-sized arrays leave the ++ // struct's Size() decoration at its unfolded value. Empirically ++ // required for the WebGPU driver (a build without it renders ++ // nothing); rationale and caveats in thirdparty/tint/patches/README.md ++ // and the introducing commit. ++ if (!capabilities_.Contains(Capability::kAllowStructMemberSizeMismatch) && ++ str->Size() < cur_offset) { + diag() << "struct size (" << str->Size() + << ") is smaller than the end of the last member (" << cur_offset << ")"; + return false; diff --git a/thirdparty/tint/patches/README.md b/thirdparty/tint/patches/README.md index f5c564de1914..cd1fcfc5fd0f 100644 --- a/thirdparty/tint/patches/README.md +++ b/thirdparty/tint/patches/README.md @@ -23,6 +23,7 @@ done | 0004 | shader_io.cc | Point size | Accept non-constant `point_size` stores | | 0005 | ir_to_program.cc | Spec constants | `@size` emission guard + capability | | 0006 | parse_num.cc | Vendoring | Replace `absl::from_chars` with `std::from_chars` | +| 0007 | validator.cc | UBO layout | Extend `kAllowStructMemberSizeMismatch` to the struct TOTAL-size check (spec-constant-sized arrays leave `Size()` at its unfolded value) | ## Logical Groups diff --git a/thirdparty/tint/src/tint/lang/core/ir/validator.cc b/thirdparty/tint/src/tint/lang/core/ir/validator.cc index a0dfe1e559be..451c918843ad 100644 --- a/thirdparty/tint/src/tint/lang/core/ir/validator.cc +++ b/thirdparty/tint/src/tint/lang/core/ir/validator.cc @@ -2257,7 +2257,15 @@ void Validator::CheckType(const core::type::Type* root, cur_offset += (member->Offset() - cur_offset) + member->MinimumRequiredSize(); } - if (str->Size() < cur_offset) { + // GODOT PATCH (patches/0007, extends patches/0002): the total-size + // check must honor kAllowStructMemberSizeMismatch like the + // member-size check above — spec-constant-sized arrays leave the + // struct's Size() decoration at its unfolded value. Empirically + // required for the WebGPU driver (a build without it renders + // nothing); rationale and caveats in thirdparty/tint/patches/README.md + // and the introducing commit. + if (!capabilities_.Contains(Capability::kAllowStructMemberSizeMismatch) && + str->Size() < cur_offset) { diag() << "struct size (" << str->Size() << ") is smaller than the end of the last member (" << cur_offset << ")"; return false; From 5362b689a7998bd7b1d33bae2102facc1e2da8ff Mon Sep 17 00:00:00 2001 From: Hesperus Date: Tue, 25 Aug 2026 22:48:49 -0700 Subject: [PATCH 3/3] WebXR over WebGPU: XRGPUBinding session glue and two-pass stereo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds immersive WebXR support to the WebGPU backend on the web platform. Build: the WebXR module was hard-gated on opengl3; this allows it to build in webgpu=yes/opengl3=no configurations (config.py). The gate is the only thing that changes for non-WebGPU builds. Runtime: binds the XR session via XRGPUBinding (not XRWebGLBinding) when the engine runs on the WebGPU driver, and renders stereo as two single-view passes into the layers of the XR texture array, since WebGPU has no multiview extension. The per-view path is opt-in via XRInterface::needs_per_view_passes() (defaults false), so every existing XR backend — WebGL/WebXR included — takes the unchanged single/multiview path. WebGL-XR behavior is unchanged: it continues to use XRWebGLBinding and the operator-supplied WebXR Layers polyfill exactly as before. --- .../webgpu/rendering_device_driver_webgpu.cpp | 58 ++++- modules/webxr/config.py | 2 +- modules/webxr/godot_webxr.h | 1 + modules/webxr/native/library_godot_webxr.js | 209 ++++++++++++--- modules/webxr/native/webxr.externs.js | 86 +++++++ modules/webxr/webxr_interface_js.cpp | 243 +++++++++++++++++- modules/webxr/webxr_interface_js.h | 14 + platform/web/js/engine/engine.js | 10 +- servers/rendering/renderer_viewport.cpp | 29 ++- servers/xr/xr_interface.h | 13 + 10 files changed, 616 insertions(+), 49 deletions(-) diff --git a/drivers/webgpu/rendering_device_driver_webgpu.cpp b/drivers/webgpu/rendering_device_driver_webgpu.cpp index c78f18dcfd0e..f3458e293343 100644 --- a/drivers/webgpu/rendering_device_driver_webgpu.cpp +++ b/drivers/webgpu/rendering_device_driver_webgpu.cpp @@ -1707,8 +1707,49 @@ RDD::TextureID RenderingDeviceDriverWebGPU::texture_create(const TextureFormat & } RDD::TextureID RenderingDeviceDriverWebGPU::texture_create_from_extension(uint64_t p_native_texture, TextureType p_type, DataFormat p_format, uint32_t p_array_layers, bool p_depth_stencil, uint32_t p_mipmaps) { - // Not supported on web platform. - ERR_FAIL_V_MSG(TextureID(), "WebGPU: texture_create_from_extension not supported."); + // Wrap an externally-owned WGPUTexture (e.g. a WebXR projection-layer + // texture imported from JS via WebGPU.importJsTexture). We take our own + // reference so texture_free's unconditional release stays symmetric; the + // compositor keeps its own reference to the underlying resource. + WGPUTexture ext = (WGPUTexture)(uintptr_t)p_native_texture; + ERR_FAIL_NULL_V_MSG(ext, TextureID(), "WebGPU: null external texture handle."); + wgpuTextureAddRef(ext); + + WGTexture *tex = new WGTexture(); + tex->handle = ext; + tex->view_source = ext; + // Trust the actual texture over the caller's declared format: XR layers + // are allocated with the binding's preferred format (often BGRA8), which + // the caller cannot know. + tex->format = wgpuTextureGetFormat(ext); + tex->rd_format = p_format; + tex->width = wgpuTextureGetWidth(ext); + tex->height = wgpuTextureGetHeight(ext); + tex->depth = 1; + tex->mipmaps = MAX(1u, p_mipmaps); + tex->layers = MAX(1u, p_array_layers); + tex->sample_count = 1; + tex->dimension = WGPUTextureDimension_2D; + tex->view_dimension = (tex->layers > 1) ? WGPUTextureViewDimension_2DArray : WGPUTextureViewDimension_2D; + tex->usage = wgpuTextureGetUsage(ext); + + WGPUTextureViewDescriptor view_desc = {}; + view_desc.format = tex->format; + view_desc.dimension = tex->view_dimension; + view_desc.baseMipLevel = 0; + view_desc.mipLevelCount = tex->mipmaps; + view_desc.baseArrayLayer = 0; + view_desc.arrayLayerCount = tex->layers; + view_desc.aspect = p_depth_stencil ? WGPUTextureAspect_DepthOnly : WGPUTextureAspect_All; + + tex->default_view = wgpuTextureCreateView(ext, &view_desc); + if (tex->default_view == nullptr) { + wgpuTextureRelease(ext); + delete tex; + ERR_FAIL_V_MSG(TextureID(), "WebGPU: failed to create view on external texture."); + } + + return TextureID(tex); } // Returns true if the format is an sRGB variant. @@ -2463,6 +2504,8 @@ RDD::DataFormat RenderingDeviceDriverWebGPU::_wgpu_to_data_format(WGPUTextureFor switch (p_format) { case WGPUTextureFormat_BGRA8Unorm: return DATA_FORMAT_B8G8R8A8_UNORM; case WGPUTextureFormat_RGBA8Unorm: return DATA_FORMAT_R8G8B8A8_UNORM; + case WGPUTextureFormat_BGRA8UnormSrgb: return DATA_FORMAT_B8G8R8A8_SRGB; + case WGPUTextureFormat_RGBA8UnormSrgb: return DATA_FORMAT_R8G8B8A8_SRGB; default: return DATA_FORMAT_MAX; } } @@ -8085,13 +8128,19 @@ RDD::PipelineID RenderingDeviceDriverWebGPU::render_pipeline_create( // Chrome ignores CompositeAlphaMode_Opaque and composites alpha=0 // against a gray/white background. The swap chain (BGRA8Unorm) is the // only BGRA render target — internal targets use RGBA formats. + // TODO(webxr): no longer strictly true — WebXR projection layers are + // BGRA8Unorm where getPreferredColorFormat() says so (desktop Chrome), + // so XR pipelines also lose alpha writes. Benign for opaque VR + // compositing; must be re-keyed on an is-swapchain flag before + // supporting immersive-ar alpha-blend output. // Stripping alpha for blended pipelines too ensures the clear value's // alpha=1 is never overwritten by shader output. if (fmt == WGPUTextureFormat_BGRA8Unorm) { mask &= ~WGPUColorWriteMask_Alpha; static int _alpha_strip_log = 0; if (_alpha_strip_log < 10) { - [[maybe_unused]] const char *sname = (p_shader.id) ? ((WGShader *)(p_shader.id))->name.utf8().get_data() : "?"; + [[maybe_unused]] CharString sname_cs = (p_shader.id) ? ((WGShader *)(p_shader.id))->name.utf8() : CharString("?"); + [[maybe_unused]] const char *sname = sname_cs.get_data(); WEBGPU_DIAG({ console.log('[ALPHA-STRIP] Pipeline #' + $0 + ' fmt=BGRA8Unorm mask=' + $1 + ' blend=' + $2 + ' shader=' + UTF8ToString($3)); }, _alpha_strip_log, (int)mask, ba.enable_blend ? 1 : 0, sname); _alpha_strip_log++; @@ -8106,7 +8155,8 @@ RDD::PipelineID RenderingDeviceDriverWebGPU::render_pipeline_create( if (!float32_blendable_supported && _is_float32_format(fmt)) { static int _f32_blend_skip_log = 0; if (_f32_blend_skip_log < 10) { - [[maybe_unused]] const char *sname = (p_shader.id) ? ((WGShader *)(p_shader.id))->name.utf8().get_data() : "?"; + [[maybe_unused]] CharString sname_cs2 = (p_shader.id) ? ((WGShader *)(p_shader.id))->name.utf8() : CharString("?"); + [[maybe_unused]] const char *sname = sname_cs2.get_data(); WEBGPU_DIAG({ console.log('[FLOAT32-BLEND-SKIP] Pipeline fmt=' + $0 + ' shader=' + UTF8ToString($1) + ' — device lacks float32-blendable, disabling blend'); }, (int)fmt, sname); _f32_blend_skip_log++; diff --git a/modules/webxr/config.py b/modules/webxr/config.py index 357dcde0b6c3..ae194f927856 100644 --- a/modules/webxr/config.py +++ b/modules/webxr/config.py @@ -1,5 +1,5 @@ def can_build(env, platform): - return env["opengl3"] and not env["disable_xr"] + return (env["opengl3"] or env["webgpu"]) and not env["disable_xr"] def configure(env): diff --git a/modules/webxr/godot_webxr.h b/modules/webxr/godot_webxr.h index f7e7794c24fb..3f45dbb4c321 100644 --- a/modules/webxr/godot_webxr.h +++ b/modules/webxr/godot_webxr.h @@ -65,6 +65,7 @@ extern void godot_webxr_uninitialize(); extern int godot_webxr_get_view_count(); extern bool godot_webxr_get_render_target_size(int *r_size); +extern int godot_webxr_get_texture_layers(); extern bool godot_webxr_get_transform_for_view(int p_view, float *r_transform); extern bool godot_webxr_get_projection_for_view(int p_view, float *r_transform); extern unsigned int godot_webxr_get_color_texture(); diff --git a/modules/webxr/native/library_godot_webxr.js b/modules/webxr/native/library_godot_webxr.js index b56585653c9f..9159e863710b 100644 --- a/modules/webxr/native/library_godot_webxr.js +++ b/modules/webxr/native/library_godot_webxr.js @@ -35,11 +35,17 @@ const GodotWebXR = { session: null, gl_binding: null, + // WebGPU path (XRGPUBinding): set when the engine runs on the WebGPU + // rendering driver (Module['preinitializedWebGPUDevice'] present). + device: null, + gpu_binding: null, layer: null, space: null, frame: null, pose: null, view_count: 1, + session_mode: '', + warned_relayer: false, input_sources: new Array(16), touches: new Array(5), onsimpleevent: null, @@ -85,15 +91,90 @@ const GodotWebXR = { }, getLayer: () => { - const new_view_count = (GodotWebXR.pose) ? GodotWebXR.pose.views.length : 1; + // The layer is created during session setup, BEFORE the first pose + // exists. Defaulting to 1 view there built a mono layer that was then + // destroyed and rebuilt as a texture-array on the first real pose — + // swapping the layer out from under the compositor mid-session, at a + // measured cost of ~10s of session startup on WebGPU. An immersive + // session is stereo by definition, so assume 2 views until a pose + // proves otherwise. + // UPSTREAM NOTE — the 1-view fallback is NOT ours. Godot's WebXR glue has long + // used `(pose) ? pose.views.length : 1`, i.e. assume ONE view until a pose + // proves otherwise. On the WebGL path that is free: if the guess is wrong + // the layer is simply rebuilt on the first stereo pose, and GL layer + // allocation is cheap. It is also correct for `inline` and for monocular + // `immersive-ar`, and it matches this module's own `view_count: 1` default, + // so it reads as deliberate defensive coding for a genuinely unknown state + // rather than an oversight. + // + // It only becomes expensive on WebGPU, where the rebuild means allocating a + // swapchain, tearing it down, and allocating a texture-array swapchain — + // measured at ~10s of session startup. So we change it ONLY on the WebGPU + // path and leave the WebGL behaviour exactly as upstream wrote it. + // + // We SUSPECT the fallback could be removed for cleanliness on both paths, + // since an immersive-vr session is stereo from the start. We have NOT tried + // it: WebGL shows no symptom, the code is upstream (and appears inherited + // from Godot proper rather than authored in this fork), and it may guard + // cases we have not enumerated. If it is not breaking anything, our + // inclination is to leave it alone. + + let new_view_count; + if (GodotWebXR.pose) { + new_view_count = GodotWebXR.pose.views.length; + } else if (GodotWebXR.gpu_binding && GodotWebXR.session_mode === 'immersive-vr') { + // WebGPU immersive-vr only: stereo by definition, so build the + // texture-array layer up front. (XRSession does not expose its + // mode; use the string this module was initialized with.) + // Other modes (inline, immersive-ar) keep the upstream 1-view + // default: handheld AR really is 1 view, and guessing 2 would + // reintroduce the destroy/rebuild this branch exists to avoid. + // An HMD AR session on WebGPU still pays one rebuild on its + // first pose; the warn below will note it. + new_view_count = 2; + } else { + // Upstream behaviour (WebGL path unchanged): assume 1 view + // until a pose proves otherwise. + new_view_count = 1; + } let layer = GodotWebXR.layer; + // 2026-08-23: creating the projection layer a SECOND time (destroy + + // re-create with a different view count) cost ~10s of session startup — + // Chrome allocates a swapchain, tears it down, allocates another. It also + // swaps the layer out from under the compositor mid-session, which the + // WebXR-WebGPU spec does not define. Measured cold-cache: ~10s -> instant. + // Silent when healthy; shouts once if the double-create ever comes back. + // WebGPU only: on WebGL the mono->stereo rebuild is upstream's normal path. + if (layer && GodotWebXR.gpu_binding && GodotWebXR.view_count !== new_view_count && !GodotWebXR.warned_relayer) { + GodotWebXR.warned_relayer = true; + console.warn('[webxr] REBUILDING the projection layer mid-session (' + + GodotWebXR.view_count + ' -> ' + new_view_count + ' views). This is the ' + + '~10s startup stall and is undefined per spec. An immersive-vr session ' + + 'is stereo from the start; the layer should be built once.'); + } + // If the view count hasn't changed since creating this layer, then // we can simply return it. if (layer && GodotWebXR.view_count === new_view_count) { return layer; } + if (GodotWebXR.gpu_binding) { + // WebGPU projection layer. Note: no depthStencilFormat — a layer + // must not declare depth it does not write (the XR compositor + // would reproject from garbage depth, causing world drift). + layer = GodotWebXR.gpu_binding.createProjectionLayer({ + textureType: new_view_count > 1 ? 'texture-array' : 'texture', + colorFormat: GodotWebXR.gpu_binding.getPreferredColorFormat(), + }); + GodotWebXR.session.updateRenderState({ layers: [layer] }); + + GodotWebXR.layer = layer; + GodotWebXR.view_count = new_view_count; + return layer; + } + if (!GodotWebXR.session || !GodotWebXR.gl_binding || !GodotWebXR.gl_binding.createProjectionLayer) { return null; } @@ -112,7 +193,7 @@ const GodotWebXR = { return layer; }, - getSubImage: () => { + getSubImage: (p_view) => { if (!GodotWebXR.pose) { return null; } @@ -121,13 +202,31 @@ const GodotWebXR = { return null; } - // Because we always use "texture-array" for multiview and "texture" - // when there is only 1 view, it should be safe to only grab the - // subimage for the first view. - return GodotWebXR.gl_binding.getViewSubImage(layer, GodotWebXR.pose.views[0]); + // The texture-array layer is shared by all views, so the color texture + // is identical for each; the per-view viewport is NOT (each view owns a + // layer of the array and its own viewport rect). Callers that need + // geometry must pass the view index. + const view = GodotWebXR.pose.views[p_view || 0] || GodotWebXR.pose.views[0]; + if (GodotWebXR.gpu_binding) { + return GodotWebXR.gpu_binding.getViewSubImage(layer, view); + } + return GodotWebXR.gl_binding.getViewSubImage(layer, view); }, getTextureId: (texture) => { + if (GodotWebXR.gpu_binding) { + // WebGPU: import the opaque GPUTexture into emdawnwebgpu and hand + // the resulting WGPUTexture pointer to C++. Cached on the JS + // object — opaque texture object identity is stable per spec + // WITHIN a session. C++ releases the import handles at session + // end, so a cached pointer from a previous session is dangling; + // key the cache on the session to force a re-import. + if (texture.__godotPtr === undefined || texture.__godotSession !== GodotWebXR.session) { + texture.__godotPtr = WebGPU.importJsTexture(texture); + texture.__godotSession = GodotWebXR.session; + } + return texture.__godotPtr; + } if (texture.name !== undefined) { return texture.name; } @@ -239,6 +338,7 @@ const GodotWebXR = { GodotWebXR.monkeyPatchRequestAnimationFrame(true); const session_mode = GodotRuntime.parseString(p_session_mode); + GodotWebXR.session_mode = session_mode; const required_features = GodotRuntime.parseString(p_required_features).split(',').map((s) => s.trim()).filter((s) => s !== ''); const optional_features = GodotRuntime.parseString(p_optional_features).split(',').map((s) => s.trim()).filter((s) => s !== ''); const requested_reference_space_types = GodotRuntime.parseString(p_requested_reference_spaces).split(',').map((s) => s.trim()); @@ -248,6 +348,12 @@ const GodotWebXR = { const oninputevent = GodotRuntime.get_func(p_on_input_event); const onsimpleevent = GodotRuntime.get_func(p_on_simple_event); + // When the engine runs on the WebGPU rendering driver, the session must + // be created with the 'webgpu' feature for XRGPUBinding to be usable. + if (Module['preinitializedWebGPUDevice'] && !required_features.includes('webgpu')) { + required_features.push('webgpu'); + } + const session_init = {}; if (required_features.length > 0) { session_init['requiredFeatures'] = required_features; @@ -288,29 +394,7 @@ const GodotWebXR = { // Store onsimpleevent so we can use it later. GodotWebXR.onsimpleevent = onsimpleevent; - const gl_context_handle = _emscripten_webgl_get_current_context(); - const gl = GL.getContext(gl_context_handle).GLctx; - GodotWebXR.gl = gl; - - gl.makeXRCompatible().then(function () { - const throwNoWebXRLayersError = () => { - throw new Error('This browser doesn\'t support WebXR Layers (which Godot requires) nor is the polyfill in use. If you are the developer of this application, please consider including the polyfill.'); - }; - - try { - GodotWebXR.gl_binding = new XRWebGLBinding(session, gl); - } catch (error) { - // We'll end up here for browsers that don't have XRWebGLBinding at all, or if the browser does support WebXR Layers, - // but is using the WebXR polyfill, so calling native XRWebGLBinding with the polyfilled XRSession won't work. - throwNoWebXRLayersError(); - } - - if (!GodotWebXR.gl_binding.createProjectionLayer) { - // On other browsers, XRWebGLBinding exists and works, but it doesn't support creating projection layers (which is - // contrary to the spec, which says this MUST be supported) and so the polyfill is required. - throwNoWebXRLayersError(); - } - + const setupLayerAndSpace = function () { // This will trigger the layer to get created. const layer = GodotWebXR.getLayer(); if (!layer) { @@ -368,11 +452,46 @@ const GodotWebXR = { } requestReferenceSpace(); - }).catch(function (error) { - const c_str = GodotRuntime.allocString(`Unable to make WebGL context compatible with WebXR: ${error}`); - onfailed(c_str); - GodotRuntime.free(c_str); - }); + }; + + if (Module['preinitializedWebGPUDevice']) { + // WebGPU driver path: bind the session to the shared GPUDevice. + try { + GodotWebXR.device = Module['preinitializedWebGPUDevice']; + GodotWebXR.gpu_binding = new XRGPUBinding(session, GodotWebXR.device); + setupLayerAndSpace(); + } catch (error) { + const c_str = GodotRuntime.allocString(`Unable to create XRGPUBinding: ${error}`); + onfailed(c_str); + GodotRuntime.free(c_str); + } + } else { + const gl_context_handle = _emscripten_webgl_get_current_context(); + const gl = GL.getContext(gl_context_handle).GLctx; + GodotWebXR.gl = gl; + + gl.makeXRCompatible().then(function () { + const throwNoWebXRLayersError = () => { + throw new Error('This browser doesn\'t support WebXR Layers (which Godot requires) nor is the polyfill in use. If you are the developer of this application, please consider including the polyfill.'); + }; + + try { + GodotWebXR.gl_binding = new XRWebGLBinding(session, gl); + } catch (error) { + throwNoWebXRLayersError(); + } + + if (!GodotWebXR.gl_binding.createProjectionLayer) { + throwNoWebXRLayersError(); + } + + setupLayerAndSpace(); + }).catch(function (error) { + const c_str = GodotRuntime.allocString(`Unable to make WebGL context compatible with WebXR: ${error}`); + onfailed(c_str); + GodotRuntime.free(c_str); + }); + } }).catch(function (error) { const c_str = GodotRuntime.allocString(`Unable to start session: ${error}`); onfailed(c_str); @@ -391,11 +510,15 @@ const GodotWebXR = { GodotWebXR.session = null; GodotWebXR.gl_binding = null; + GodotWebXR.device = null; + GodotWebXR.gpu_binding = null; GodotWebXR.layer = null; GodotWebXR.space = null; GodotWebXR.frame = null; GodotWebXR.pose = null; GodotWebXR.view_count = 1; + GodotWebXR.session_mode = ''; + GodotWebXR.warned_relayer = false; GodotWebXR.input_sources = new Array(16); GodotWebXR.touches = new Array(5); GodotWebXR.onsimpleevent = null; @@ -416,6 +539,17 @@ const GodotWebXR = { return view_count > 0 ? view_count : 1; }, + godot_webxr_get_texture_layers__proxy: 'sync', + godot_webxr_get_texture_layers__sig: 'i', + godot_webxr_get_texture_layers: function () { + const subimage = GodotWebXR.getSubImage(0); + if (subimage === null || !subimage.colorTexture) { + return 0; + } + // Authoritative: the compositor's own texture, not a pose-derived guess. + return subimage.colorTexture.depthOrArrayLayers || 1; + }, + godot_webxr_get_render_target_size__proxy: 'sync', godot_webxr_get_render_target_size__sig: 'ii', godot_webxr_get_render_target_size: function (r_size) { @@ -481,6 +615,10 @@ const GodotWebXR = { godot_webxr_get_depth_texture__proxy: 'sync', godot_webxr_get_depth_texture__sig: 'i', godot_webxr_get_depth_texture: function () { + if (GodotWebXR.gpu_binding) { + // v0: no depth in the WebGPU projection layer. + return 0; + } const subimage = GodotWebXR.getSubImage(); if (subimage === null) { return 0; @@ -494,6 +632,9 @@ const GodotWebXR = { godot_webxr_get_velocity_texture__proxy: 'sync', godot_webxr_get_velocity_texture__sig: 'i', godot_webxr_get_velocity_texture: function () { + if (GodotWebXR.gpu_binding) { + return 0; + } const subimage = GodotWebXR.getSubImage(); if (subimage === null) { return 0; diff --git a/modules/webxr/native/webxr.externs.js b/modules/webxr/native/webxr.externs.js index 18125d786980..442a8c4fb116 100644 --- a/modules/webxr/native/webxr.externs.js +++ b/modules/webxr/native/webxr.externs.js @@ -1259,3 +1259,89 @@ XRMediaBinding.prototype.createEquirectLayer = function(video, init) {}; * @type {Array} */ XRRenderState.prototype.layers; + +/* + * WebXR-WebGPU Binding (https://immersive-web.github.io/webxr-webgpu-binding/) + */ + +/** + * @constructor XRGPUProjectionLayerInit + */ +function XRGPUProjectionLayerInit() {} + +/** + * @type {string} + */ +XRGPUProjectionLayerInit.prototype.textureType; + +/** + * @type {string} + */ +XRGPUProjectionLayerInit.prototype.colorFormat; + +/** + * @type {string} + */ +XRGPUProjectionLayerInit.prototype.depthStencilFormat; + +/** + * @type {number} + */ +XRGPUProjectionLayerInit.prototype.scaleFactor; + +/** + * @constructor XRGPUSubImage + * @extends {XRSubImage} + */ +function XRGPUSubImage() {} + +/** + * @type {GPUTexture} + */ +XRGPUSubImage.prototype.colorTexture; + +/** + * @type {?GPUTexture} + */ +XRGPUSubImage.prototype.depthStencilTexture; + +/** + * @type {?GPUTexture} + */ +XRGPUSubImage.prototype.motionVectorTexture; + +/** + * @type {?number} + */ +XRGPUSubImage.prototype.imageIndex; + +/** + * @constructor XRGPUBinding + * + * @param {XRSession} session + * @param {GPUDevice} device + */ +function XRGPUBinding(session, device) {} + +/** + * @type {number} + */ +XRGPUBinding.prototype.nativeProjectionScaleFactor; + +/** + * @return {string} + */ +XRGPUBinding.prototype.getPreferredColorFormat = function () {}; + +/** + * @param {XRGPUProjectionLayerInit} init + * @return {XRProjectionLayer} + */ +XRGPUBinding.prototype.createProjectionLayer = function (init) {}; + +/** + * @param {XRProjectionLayer} layer + * @param {XRView} view + * @return {XRGPUSubImage} + */ +XRGPUBinding.prototype.getViewSubImage = function (layer, view) {}; diff --git a/modules/webxr/webxr_interface_js.cpp b/modules/webxr/webxr_interface_js.cpp index 216ecaca08ff..31f005a901dd 100644 --- a/modules/webxr/webxr_interface_js.cpp +++ b/modules/webxr/webxr_interface_js.cpp @@ -36,7 +36,11 @@ #include "core/input/input.h" #include "core/os/os.h" +#include "core/templates/local_vector.h" +#ifdef GLES3_ENABLED #include "drivers/gles3/storage/texture_storage.h" +#endif +#include "servers/rendering/rendering_device.h" #include "scene/main/scene_tree.h" #include "scene/main/window.h" #include "scene/scene_string_names.h" @@ -47,6 +51,13 @@ #include #include +#ifdef WEBGPU_ENABLED +// For querying the real format of (and releasing) XR-compositor textures +// imported from JS. The emdawnwebgpu include path is on global CCFLAGS +// (--use-port=emdawnwebgpu, platform/web/detect.py) whenever webgpu=yes. +#include +#endif + void _emwebxr_on_session_supported(char *p_session_mode, int p_supported) { XRServer *xr_server = XRServer::get_singleton(); ERR_FAIL_NULL(xr_server); @@ -278,7 +289,22 @@ uint32_t WebXRInterfaceJS::get_capabilities() const { } uint32_t WebXRInterfaceJS::get_view_count() { - return godot_webxr_get_view_count(); + uint32_t view_count = godot_webxr_get_view_count(); + + // Godot's RenderingDevice renderer implements multi-view render passes + // exclusively via multiview (rendering_device.cpp:2804 hard-fails without + // driver support), and WebGPU exposes no multiview extension. Clamp to a + // single view so the engine configures single-view render buffers; stereo + // is still delivered — each eye is drawn as its own single-view pass (see + // needs_per_view_passes) against one array layer at a time. The projection + // layer itself stays a texture array; the compositor gets both views each + // frame. + RenderingDevice *rd = RenderingServer::get_singleton()->get_rendering_device(); + if (view_count > 1 && rd != nullptr && !rd->has_feature(RenderingDevice::SUPPORTS_MULTIVIEW)) { + return 1; + } + + return view_count; } bool WebXRInterfaceJS::is_initialized() const { @@ -295,10 +321,12 @@ bool WebXRInterfaceJS::initialize() { return false; } - if (session_mode == "immersive-vr" && !GLES3::Config::get_singleton()->multiview_supported) { +#ifdef GLES3_ENABLED + if (RenderingServer::get_singleton()->get_rendering_device() == nullptr && session_mode == "immersive-vr" && !GLES3::Config::get_singleton()->multiview_supported) { emit_signal("session_failed", "Stereo rendering in Godot requires multiview, but this web browser doesn't support it."); return false; } +#endif // GLES3_ENABLED if (requested_reference_space_types.is_empty()) { emit_signal("session_failed", "No reference spaces were requested."); @@ -368,6 +396,30 @@ void WebXRInterfaceJS::uninitialize() { godot_webxr_uninitialize(); + // RenderingDevice (WebGPU) path: view slices are shared views of the + // cached parents and must be freed BEFORE them — freeing only the + // parents here would leave view_slice_cache holding dead RIDs across the + // session boundary, and the next session's first pre_draw_viewport would + // free stale IDs ("Attempted to free invalid ID"). _release_view_slices() + // owns that ordering (slices, then parents) and clears the caches; it is + // a no-op without a RenderingDevice. + _release_view_slices(); + +#ifdef WEBGPU_ENABLED + // Drop this module's owning reference on every WGPUTexture imported + // this session (the JS-side import creates each with one reference). + // The session-lifetime list — NOT the per-frame texture_cache, which + // only holds the last frame's textures and is empty if the session + // ended pose-less (headset taken off) — otherwise each enter/exit VR + // cycle would leak the import handles and pin the JS GPUTexture + // objects. The JS import cache is keyed on the session, so the next + // session re-imports rather than reusing a released pointer. + for (const uint64_t handle : imported_texture_handles) { + wgpuTextureRelease((WGPUTexture)(uintptr_t)handle); + } + imported_texture_handles.clear(); +#endif // WEBGPU_ENABLED +#ifdef GLES3_ENABLED GLES3::TextureStorage *texture_storage = GLES3::TextureStorage::get_singleton(); if (texture_storage != nullptr) { for (KeyValue &E : texture_cache) { @@ -378,6 +430,7 @@ void WebXRInterfaceJS::uninitialize() { texture_storage->texture_free(E.value); } } +#endif // GLES3_ENABLED texture_cache.clear(); reference_space_type.clear(); @@ -459,6 +512,12 @@ Transform3D WebXRInterfaceJS::get_transform_for_view(uint32_t p_view, const Tran ERR_FAIL_NULL_V(xr_server, p_cam_transform); ERR_FAIL_COND_V(!initialized, p_cam_transform); + // With per-view passes the engine renders one view at a time and always asks + // for view 0; map that onto the eye currently being drawn. + if (needs_per_view_passes()) { + p_view = current_view; + } + float js_matrix[16]; bool has_transform = godot_webxr_get_transform_for_view(p_view, js_matrix); if (!has_transform) { @@ -478,6 +537,10 @@ Projection WebXRInterfaceJS::get_projection_for_view(uint32_t p_view, double p_a ERR_FAIL_COND_V(!initialized, view); + if (needs_per_view_passes()) { + p_view = current_view; + } + float js_matrix[16]; bool has_projection = godot_webxr_get_projection_for_view(p_view, js_matrix); if (!has_projection) { @@ -499,6 +562,26 @@ Projection WebXRInterfaceJS::get_projection_for_view(uint32_t p_view, double p_a } bool WebXRInterfaceJS::pre_draw_viewport(RID p_render_target) { + RenderingDevice *rd = RenderingServer::get_singleton()->get_rendering_device(); + if (rd != nullptr) { + // RenderingDevice (WebGPU) path: the generic viewport override + // (renderer_viewport.cpp) consumes get_color_texture() each frame; + // no FBO reattach machinery is needed. + // + // XR textures are "opaque": the compositor may swap the underlying + // resource each frame even when the JS object identity is unchanged, so + // the imported wrapper and its per-view slices must be rebuilt per frame. + // This MUST happen once per FRAME, not once per view -- with two-pass + // stereo, get_color_texture_for_view() is called once per eye, and + // tearing down there destroys the slice the previous eye is still + // rendering into ("Attempted to free invalid ID", every frame). + // Slices are shared views of the parent, so they are released first. + _release_view_slices(); + color_texture = _get_color_texture(); + depth_texture = _get_depth_texture(); + return true; + } +#ifdef GLES3_ENABLED GLES3::TextureStorage *texture_storage = GLES3::TextureStorage::get_singleton(); if (texture_storage == nullptr) { return false; @@ -521,17 +604,26 @@ bool WebXRInterfaceJS::pre_draw_viewport(RID p_render_target) { texture_storage->render_target_set_reattach_textures(p_render_target, true); return true; +#else + return false; +#endif // GLES3_ENABLED } Vector WebXRInterfaceJS::post_draw_viewport(RID p_render_target, const Rect2 &p_screen_rect) { Vector blit_to_screen; + if (RenderingServer::get_singleton()->get_rendering_device() != nullptr) { + return blit_to_screen; + } + +#ifdef GLES3_ENABLED GLES3::TextureStorage *texture_storage = GLES3::TextureStorage::get_singleton(); if (texture_storage == nullptr) { return blit_to_screen; } texture_storage->render_target_set_reattach_textures(p_render_target, false); +#endif // GLES3_ENABLED return blit_to_screen; } @@ -560,14 +652,85 @@ RID WebXRInterfaceJS::_get_texture(unsigned int p_texture_id) { return cache->get(); } + uint32_t view_count = godot_webxr_get_view_count(); + Size2 texture_size = get_render_target_size(); + + RenderingDevice *rd = RenderingServer::get_singleton()->get_rendering_device(); + if (rd != nullptr) { +#ifdef WEBGPU_ENABLED + // The framebuffer's view_count must equal the wrapped texture's array + // layer count (rendering_device.cpp:3272). Trust the compositor's + // texture rather than the view count, which falls back to 1 before the + // first pose arrives and would silently produce an unusable + // single-layer framebuffer. RD/WebGPU path only: on WebGL the sub-image + // color texture has no layer count to query, and the GLES3 path below + // uses view_count directly. + uint32_t texture_layers = (uint32_t)godot_webxr_get_texture_layers(); + if (texture_layers == 0) { + texture_layers = view_count; + } + if (texture_layers != view_count) { + WARN_PRINT_ONCE(vformat("WebXR: layer texture has %d layers but %d views reported; using %d.", texture_layers, view_count, texture_layers)); + } + + // WebGPU path: p_texture_id is a WGPUTexture pointer imported from JS + // (WebGPU.importJsTexture). The driver wraps it without taking + // ownership of the underlying XR-compositor resource. + // + // The projection layer is allocated with getPreferredColorFormat() — + // observed bgra8unorm on desktop Chrome, typically rgba8unorm on + // Android — so the + // declared RD format must be read from the texture, not assumed. Render + // pipelines take their color-target format from this declared format + // (the driver's attachment-view format is already read from the texture + // itself); declaring the wrong one makes every draw fail validation. + RenderingDevice::DataFormat rd_format; + switch (wgpuTextureGetFormat((WGPUTexture)(uintptr_t)p_texture_id)) { + case WGPUTextureFormat_BGRA8Unorm: + rd_format = RenderingDevice::DATA_FORMAT_B8G8R8A8_UNORM; + break; + case WGPUTextureFormat_RGBA8Unorm: + rd_format = RenderingDevice::DATA_FORMAT_R8G8B8A8_UNORM; + break; + case WGPUTextureFormat_BGRA8UnormSrgb: + rd_format = RenderingDevice::DATA_FORMAT_B8G8R8A8_SRGB; + break; + case WGPUTextureFormat_RGBA8UnormSrgb: + rd_format = RenderingDevice::DATA_FORMAT_R8G8B8A8_SRGB; + break; + default: + WARN_PRINT_ONCE("WebXR: projection layer texture has an unexpected format; assuming BGRA8Unorm."); + rd_format = RenderingDevice::DATA_FORMAT_B8G8R8A8_UNORM; + break; + } + + RID texture = rd->texture_create_from_extension( + texture_layers == 1 ? RenderingDevice::TEXTURE_TYPE_2D : RenderingDevice::TEXTURE_TYPE_2D_ARRAY, + rd_format, + RenderingDevice::TEXTURE_SAMPLES_1, + RenderingDevice::TEXTURE_USAGE_COLOR_ATTACHMENT_BIT | RenderingDevice::TEXTURE_USAGE_SAMPLING_BIT, + (uint64_t)p_texture_id, + (uint64_t)texture_size.width, + (uint64_t)texture_size.height, + 1, + texture_layers, + 1); + texture_cache.insert(p_texture_id, texture); + if (!imported_texture_handles.has((uint64_t)p_texture_id)) { + imported_texture_handles.push_back((uint64_t)p_texture_id); + } + return texture; +#else + return RID(); +#endif // WEBGPU_ENABLED + } + +#ifdef GLES3_ENABLED GLES3::TextureStorage *texture_storage = GLES3::TextureStorage::get_singleton(); if (texture_storage == nullptr) { return RID(); } - uint32_t view_count = godot_webxr_get_view_count(); - Size2 texture_size = get_render_target_size(); - RID texture = texture_storage->texture_create_from_native_handle( view_count == 1 ? RS::TEXTURE_TYPE_2D : RS::TEXTURE_TYPE_LAYERED, Image::FORMAT_RGBA8, @@ -580,6 +743,76 @@ RID WebXRInterfaceJS::_get_texture(unsigned int p_texture_id) { texture_cache.insert(p_texture_id, texture); return texture; +#else + return RID(); +#endif // GLES3_ENABLED +} + +bool WebXRInterfaceJS::needs_per_view_passes() const { + RenderingDevice *rd = RenderingServer::get_singleton()->get_rendering_device(); + if (rd == nullptr || !initialized) { + return false; + } + // Only when the driver cannot do multiview but the device is stereo. + return !rd->has_feature(RenderingDevice::SUPPORTS_MULTIVIEW) && godot_webxr_get_view_count() > 1; +} + +uint32_t WebXRInterfaceJS::get_per_view_pass_count() const { + return needs_per_view_passes() ? (uint32_t)godot_webxr_get_view_count() : 1; +} + +void WebXRInterfaceJS::set_current_view(uint32_t p_view) { + current_view = p_view; +} + +void WebXRInterfaceJS::_release_view_slices() { + RenderingDevice *rd = RenderingServer::get_singleton()->get_rendering_device(); + if (rd == nullptr) { + return; + } + // Slices are shared views created from a parent in texture_cache; releasing + // the parent first would leave them dangling and their free would land in + // RenderingDevice::_free_internal's final else. + for (uint32_t i = 0; i < 2; i++) { + if (view_slice_cache[i].is_valid()) { + rd->free_rid(view_slice_cache[i]); + view_slice_cache[i] = RID(); + } + } + for (const KeyValue &E : texture_cache) { + if (E.value.is_valid()) { + rd->free_rid(E.value); + } + } + texture_cache.clear(); + color_texture = RID(); + depth_texture = RID(); +} + +RID WebXRInterfaceJS::get_color_texture_for_view(uint32_t p_view) { + RenderingDevice *rd = RenderingServer::get_singleton()->get_rendering_device(); + ERR_FAIL_NULL_V(rd, RID()); + ERR_FAIL_UNSIGNED_INDEX_V(p_view, 2, RID()); + + // Pure accessor: the per-frame teardown lives in pre_draw_viewport(), so + // both eyes of a frame share one imported parent and keep their slices + // alive for the whole frame. + RID source = color_texture.is_valid() ? color_texture : _get_color_texture(); + if (source.is_null()) { + return RID(); + } + + if (view_slice_cache[p_view].is_null()) { + // A renderable WebGPU view must cover exactly one array layer. + view_slice_cache[p_view] = rd->texture_create_shared_from_slice(RenderingDevice::TextureView(), source, p_view, 0, 1, RenderingDevice::TEXTURE_SLICE_2D); + } + + return view_slice_cache[p_view]; +} + +RID WebXRInterfaceJS::get_depth_texture_for_view(uint32_t p_view) { + // v0: no depth submitted to the projection layer. + return RID(); } RID WebXRInterfaceJS::get_color_texture() { diff --git a/modules/webxr/webxr_interface_js.h b/modules/webxr/webxr_interface_js.h index 4df9b0ed6159..48d046dc840f 100644 --- a/modules/webxr/webxr_interface_js.h +++ b/modules/webxr/webxr_interface_js.h @@ -63,6 +63,13 @@ class WebXRInterfaceJS : public WebXRInterface { Size2 render_targetsize; RBMap texture_cache; + RID view_slice_cache[2]; + uint32_t current_view = 0; + // WGPUTexture pointers imported from JS this session (owning references, + // released at uninitialize). Distinct handles are few (the layer's + // swapchain images), but the per-frame texture_cache only ever holds the + // LAST frame's, so session-lifetime tracking is required to release all. + LocalVector imported_texture_handles; struct Touch { bool is_touching = false; Vector2 position; @@ -84,6 +91,7 @@ class WebXRInterfaceJS : public WebXRInterface { RID color_texture; RID depth_texture; + void _release_view_slices(); RID _get_color_texture(); RID _get_depth_texture(); RID _get_texture(unsigned int p_texture_id); @@ -133,6 +141,12 @@ class WebXRInterfaceJS : public WebXRInterface { virtual Projection get_projection_for_view(uint32_t p_view, double p_aspect, double p_z_near, double p_z_far) override; virtual bool pre_draw_viewport(RID p_render_target) override; virtual Vector post_draw_viewport(RID p_render_target, const Rect2 &p_screen_rect) override; + virtual bool needs_per_view_passes() const override; + virtual uint32_t get_per_view_pass_count() const override; + virtual RID get_color_texture_for_view(uint32_t p_view) override; + virtual RID get_depth_texture_for_view(uint32_t p_view) override; + virtual void set_current_view(uint32_t p_view) override; + virtual RID get_color_texture() override; virtual RID get_depth_texture() override; virtual RID get_velocity_texture() override; diff --git a/platform/web/js/engine/engine.js b/platform/web/js/engine/engine.js index 4a3d8b7757b8..9f47d37aeacf 100644 --- a/platform/web/js/engine/engine.js +++ b/platform/web/js/engine/engine.js @@ -301,9 +301,13 @@ const Engine = (function () { + 'Try using a recent version of Chrome, Edge, or Firefox.' )); } - return navigator.gpu.requestAdapter(adapterOptions || { - powerPreference: 'high-performance', - }).then(function (adapter) { + return navigator.gpu.requestAdapter(Object.assign({ + powerPreference: 'high-performance', + // Required for XRGPUBinding: the adapter must be XR-compatible + // BEFORE the device is created (WebXR-WebGPU binding spec). + // Default on; an explicit caller option still wins. + xrCompatible: true, + }, adapterOptions || {})).then(function (adapter) { if (!adapter) { return Promise.reject(new Error( 'WebGPU adapter not found. Your GPU may not support WebGPU.' diff --git a/servers/rendering/renderer_viewport.cpp b/servers/rendering/renderer_viewport.cpp index 21c852c5aea2..0b7314f78957 100644 --- a/servers/rendering/renderer_viewport.cpp +++ b/servers/rendering/renderer_viewport.cpp @@ -867,8 +867,33 @@ void RendererViewport::draw_viewports(bool p_swap_buffers) { // render... RSG::scene->set_debug_draw_mode(vp->debug_draw); - // and draw viewport - _draw_viewport(vp); + if (xr_interface->needs_per_view_passes()) { + // The driver has no multiview, so each view is drawn as an + // ordinary single-view pass into its own layer of the XR + // texture array. The interface supplies the per-view target + // and reports which view is current for camera/projection. + const uint32_t xr_view_count = xr_interface->get_per_view_pass_count(); + for (uint32_t v = 0; v < xr_view_count; v++) { + xr_interface->set_current_view(v); + RSG::texture_storage->render_target_set_override(vp->render_target, + xr_interface->get_color_texture_for_view(v), + xr_interface->get_depth_texture_for_view(v), + RID(), + RID()); + _draw_viewport(vp); + } + xr_interface->set_current_view(0); + // Restore the frame-level override set above, so the render + // target is not left pointing at the last view's slice. + RSG::texture_storage->render_target_set_override(vp->render_target, + xr_interface->get_color_texture(), + xr_interface->get_depth_texture(), + xr_interface->get_velocity_texture(), + xr_interface->get_velocity_depth_texture()); + } else { + // and draw viewport + _draw_viewport(vp); + } // commit our eyes Vector blits = xr_interface->post_draw_viewport(vp->render_target, vp->viewport_to_screen_rect); diff --git a/servers/xr/xr_interface.h b/servers/xr/xr_interface.h index 5af4f947aae8..267c25c97de3 100644 --- a/servers/xr/xr_interface.h +++ b/servers/xr/xr_interface.h @@ -147,6 +147,19 @@ class XRInterface : public RefCounted { virtual Size2i get_velocity_target_size(); virtual Rect2i get_render_region(); virtual void pre_render() {} + /* Return true when the interface needs one render pass PER VIEW instead of a + single multiview pass — e.g. WebGPU, which has no multiview extension. The + viewport then draws once per view, and get_color_texture_for_view() supplies + the per-view target. */ + virtual bool needs_per_view_passes() const { return false; } + virtual RID get_color_texture_for_view(uint32_t p_view) { return RID(); } + virtual RID get_depth_texture_for_view(uint32_t p_view) { return RID(); } + /* Which view the engine is currently rendering, when drawing per-view passes. + The interface reports this view's transform/projection through the existing + get_transform_for_view(0)/get_projection_for_view(0) calls. */ + virtual void set_current_view(uint32_t p_view) {} + virtual uint32_t get_per_view_pass_count() const { return 1; } + virtual bool pre_draw_viewport(RID p_render_target) { return true; } /* inform XR interface we are about to start our viewport draw process */ virtual Vector post_draw_viewport(RID p_render_target, const Rect2 &p_screen_rect) = 0; /* inform XR interface we finished our viewport draw process */ virtual void end_frame() {}