diff --git a/.github/workflows/native-linux.yml b/.github/workflows/native-linux.yml new file mode 100644 index 000000000..8d7adf07f --- /dev/null +++ b/.github/workflows/native-linux.yml @@ -0,0 +1,72 @@ +name: Native Linux document contracts +on: + push: + branches: [codex/linux-headless-platform] + paths: + - 'src/WebScene.NativeWeb/**' + - 'src/WebScene.Sdk/**' + - 'experiments/WebScene.NativeEngine.Probe/native/**' + - 'tooling/webscene-uic/**' + - 'tests/NativeWeb/**' + - 'tests/SDKLinux/**' + - '.github/workflows/native-linux.yml' + pull_request: + paths: + - 'src/WebScene.NativeWeb/**' + - 'src/WebScene.Sdk/**' + - 'experiments/WebScene.NativeEngine.Probe/native/**' + - 'tooling/webscene-uic/**' + - 'tests/NativeWeb/**' + - 'tests/SDKLinux/**' + - '.github/workflows/native-linux.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Install pinned build tools + run: | + sudo apt-get update + sudo apt-get install -y clang-18 clang-tools-18 ninja-build + python3 -m pip install 'cmake==3.31.6' + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: native-linux-cargo-${{ hashFiles('experiments/WebScene.NativeEngine.Probe/native/html_parser/Cargo.lock') }} + - name: Build and run original native document/compiler contracts + shell: bash + run: | + set -euo pipefail + cmake -G Ninja -S src/WebScene.NativeWeb -B artifacts/native-linux -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_NATIVE_WEB_GPU=OFF + cmake --build artifacts/native-linux --parallel 2 2>&1 | tee artifacts/native-linux-build.log + ctest --test-dir artifacts/native-linux --output-on-failure --output-junit native-linux-tests.xml 2>&1 | tee artifacts/native-linux-tests.log + - name: Build independent native-only Linux SDK without optional graphics + shell: bash + run: | + set -euo pipefail + cmake -G Ninja -S src/WebScene.Sdk -B artifacts/native-sdk -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DWEBSCENE_SDK_ENABLE_WEBGPU=OFF -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/sdk" + cmake --build artifacts/native-sdk --parallel 2 2>&1 | tee artifacts/native-sdk-build.log + cmake --install artifacts/native-sdk + cmake -G Ninja -S tests/SDKLinux -B artifacts/native-sdk-consumer -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_PREFIX_PATH="$PWD/artifacts/sdk" -DWEBSCENE_TEST_WEBGPU=OFF + cmake --build artifacts/native-sdk-consumer --parallel 2 2>&1 | tee artifacts/native-sdk-consumer-build.log + ctest --test-dir artifacts/native-sdk-consumer --output-on-failure --output-junit installed-sdk-tests.xml 2>&1 | tee artifacts/native-sdk-consumer-tests.log + tar -czf artifacts/native-linux-sdk-no-gpu.tar.gz -C artifacts sdk + - uses: actions/upload-artifact@v4 + if: always() + with: + name: native-linux-contract-evidence + path: | + artifacts/*.log + artifacts/native-linux/native-linux-tests.xml + artifacts/native-linux/Testing/Temporary/ + artifacts/native-linux-sdk-no-gpu.tar.gz + artifacts/native-sdk-consumer/compiled_native + artifacts/native-sdk-consumer/installed-sdk-tests.xml + artifacts/native-sdk-consumer/Testing/Temporary/ + retention-days: 14 diff --git a/docs/graphics/native-linux-offscreen.md b/docs/graphics/native-linux-offscreen.md new file mode 100644 index 000000000..6ca8b6ed6 --- /dev/null +++ b/docs/graphics/native-linux-offscreen.md @@ -0,0 +1,59 @@ +# Native Linux offscreen WebGPU and SDK + +This adds the WebScene half of AppScene's selectable `headless` platform. It is a native-only Linux x86_64 SDK profile, not a new application host. AppScene owns input, scheduling and capture composition; WebScene owns native document/layout and the application WebGPU device and immutable image leases. + +## Profiles and selection + +`src/WebScene.Sdk` selects the Linux producer without enabling Objective-C++. It installs Core, NativeWeb, SharedCSS, Compiler and, by default, WebGPU. Runtime/V8 is not built or installed. `WEBSCENE_SDK_ENABLE_WEBGPU=OFF` produces the independent non-GPU native SDK. Native consumers compile HTML/templates at build time, explicitly select `CSS_BACKEND shared`, and link `WebScene::SharedCSS`. Function/data sections plus native link garbage collection exclude runtime HTML parser code; shared CSS parsing is allowed. + +On Linux `native_webgpu_surface` selects `native_webgpu_offscreen_surface`. Existing macOS Metal/IOSurface and Windows D3D12 surface selection remains unchanged. `WEBSCENE_NATIVE_WEBGPU_OFFSCREEN` can explicitly select the offscreen surface at compile time, but only the Linux profile is qualified by this work. + +## Ownership and completion + +The offscreen backend uses the existing `dawn_canvas_images` bounded three-slot pool, submission status and immutable image lease contracts from the macOS/Windows foundation. An application obtains `current_texture()`, records/submits its Dawn work and calls `present()`. `retire_submitted` holds producer ownership through the queue's submitted-work completion, even if the application drops its snapshot. Completion uses Dawn's spontaneous callback mode rather than a UI frame timer. A snapshot resolves only after success. No early external-image or producer-wait capability is advertised. + +Resize increments the allocation generation. Already accepted leases remain valid while new-size drawing is pending. Pool byte limits and consumers prevent reuse while an image is in use. Device mismatch, foreign surface-thread access, unsupported image format, failed submission, software selection policy and capture size limits are explicit failures. Surface use/destruction is owner-thread-only; retained image dependencies keep the originating device alive after the surface is gone. + +## Captures are explicit readbacks + +Normal `present()` and `process_events()` keep textures on the Dawn device and do not copy pixels to the CPU. `capture_offscreen_image()` is a separately requested diagnostic/export operation. It retains an image consumer through queue completion, copies BGRA8/RGBA8 into a 256-byte-row-aligned map buffer, and returns tightly packed RGBA8 with the original alpha convention. Mapping timeout/cancellation cannot prematurely release the GPU image consumer. A capture counter records completed readbacks. + +AppScene may then raster-compose those captured textures with the same scene-command compositor used by its existing platform. This is an explicit screenshot operation, **not** zero-copy Linux desktop presentation. The capture may wait for GPU completion; ordinary frame processing does not perform queue-idle waits or readbacks. + +## Backend policy + +The Linux backend is Dawn Vulkan at the existing pinned Dawn/Tint revision `2ca8cbfe0f8275aa0f739e7b6b4345a16e2f0378`. No opportunistic dependency upgrade is required. By default non-hardware adapters are rejected. Automated environments must explicitly opt into software: + +```sh +export WEBSCENE_HEADLESS_FORCE_SOFTWARE_ADAPTER=1 +export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json +``` + +Locate the actual ICD filename on the host; it is distribution-specific. Record `vulkaninfo --summary`, the SDK manifest and application output. Lavapipe/SwiftShader results establish offscreen software execution, never hardware performance or X11/Wayland compatibility. + +## Build and validation + +The paired AppScene `eng/sdk/build_headless.py` builds, installs, relocates and qualifies the combined SDK. Independently, the original native document/compiler suite can run without graphics: + +```sh +cmake -G Ninja -S src/WebScene.NativeWeb -B artifacts/native-linux \ + -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_BUILD_TYPE=Release \ + -DWEBSCENE_NATIVE_WEB_GPU=OFF +cmake --build artifacts/native-linux --parallel 2 +ctest --test-dir artifacts/native-linux --output-on-failure + +cmake -G Ninja -S src/WebScene.Sdk -B artifacts/native-sdk \ + -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 \ + -DCMAKE_BUILD_TYPE=Release -DWEBSCENE_SDK_ENABLE_WEBGPU=OFF \ + -DCMAKE_INSTALL_PREFIX="$PWD/artifacts/sdk" +cmake --build artifacts/native-sdk --parallel 2 +cmake --install artifacts/native-sdk +``` + +The Linux SDK records the producer's exact C++ compiler version and rejects a mismatched consumer. The current CI toolchain is Ubuntu Clang 18.1.3 and CMake 3.31.6, with the producer's libstdc++ ABI. It does not change the existing macOS LLVM 22.1.1 profile. + +`tests/SDKLinux` is an independent installed-SDK consumer. It tests compiled shared-CSS UI, native input and actual offscreen GPU pixels; unaligned readback rows; bounded image reuse; retained images after resize; foreign-device and thread rejection; and leases surviving surface destruction. The paired AppScene tests additionally exercise the unchanged NativeWebGPU sample through its real native host and composed PNGs. No missing-adapter result is a pass or skip. + +## Limits + +This does not implement or qualify Linux external-memory FD/dma-buf sharing, Vulkan-to-EGL composition, an X11/Wayland window host, Windows/Linux video decoding, WebGL browser bindings, full CTS/WPT conformance, or hardware GPU timing. It does not qualify Kestrel's complete application behavior or complete its C++ migration. Those are separate requirements, including issue #46. No parent epic is closed by this offscreen SDK milestone. diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h index fbaa13bbe..efb884a59 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/dawn_canvas_images.h @@ -60,6 +60,29 @@ class dawn_canvas_images { }); return submitted_frame{std::move(*image),std::move(status)}; } + // Native applications submit their own command buffers before presenting. + // Even if retaining a snapshot hits the ticket limit, keep the producer + // reserved until ALL work already submitted to its queue has completed. + std::optional retire_submitted(frame&& input, + std::shared_ptr completion={}, wgpu::Future* submitted_future=nullptr) { + check_thread(); + if (!input.producer.belongs_to(storage_.get())) + throw std::invalid_argument("foreign canvas producer"); + auto status=std::make_shared>(submission_status::pending); + auto pending=std::make_shared(std::move(input)); + pending->producer.begin(); + auto image=pending->producer.publish(); + auto future=storage_->device.GetQueue().OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [pending,status,completion](wgpu::QueueWorkDoneStatus result,wgpu::StringView) { + pending->producer.complete(); + status->store(result==wgpu::QueueWorkDoneStatus::Success + ? submission_status::success : submission_status::failed,std::memory_order_release); + if (completion) completion->signal(); + }); + if (submitted_future) *submitted_future=future; + if (!image) return {}; // callback still owns the submitted producer + return submitted_frame{std::move(*image),std::move(status)}; + } dawn_canvas_images(wgpu::Device device,uint64_t byte_limit,size_t tickets=128,std::shared_ptr wake={}) :storage_(std::make_shared(std::move(device))),pool_(storage_,tickets,std::move(wake)),byte_limit_(byte_limit) { if (!storage_->device || !byte_limit) throw std::invalid_argument("Dawn image storage requires device and budget"); diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h new file mode 100644 index 000000000..49086767d --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_offscreen_surface.h @@ -0,0 +1,277 @@ +#pragma once +#include "native_webgpu_device.h" +#include "dawn_canvas_images.h" +#include "image_lease_abi.h" +#include +#include +#include +#include +#include +#include +#include + +namespace webscene::graphics { +// Dedicated native completion progress. Wait on submitted-work futures instead +// of driving a render timer or waiting on the application/owner thread. Dawn's +// spontaneous callback mode alone does not guarantee progress when all frames +// are paused. This service never touches document state or presents a frame. +class offscreen_completion_service { + struct state { + std::shared_ptr gpu; + std::mutex mutex; + std::condition_variable wake; + std::deque pending; + bool closing=false; + explicit state(std::shared_ptr value):gpu(std::move(value)) {} + }; + std::shared_ptr state_; + std::thread worker_; + static void work(const std::shared_ptr& state) noexcept { + for(;;) { + wgpu::Future future; + { + std::unique_lock lock(state->mutex); + state->wake.wait(lock,[&]{return state->closing || !state->pending.empty();}); + if(state->pending.empty()) return; + future=state->pending.front();state->pending.pop_front(); + } + // The only blocking GPU wait is on this completion worker (or an + // explicitly requested capture). Timeout is a device failure, + // never a fabricated successful frame or owner-thread GPU wait. + if(state->gpu->instance.WaitAny(future,30'000'000'000ULL)!=wgpu::WaitStatus::Success) { + state->gpu->failed->store(true); + state->gpu->device.Destroy(); + state->gpu->instance.WaitAny(future,0); + } + } + } +public: + explicit offscreen_completion_service(std::shared_ptr gpu) + :state_(std::make_shared(std::move(gpu))),worker_([state=state_]{work(state);}) {} + offscreen_completion_service(const offscreen_completion_service&)=delete; + offscreen_completion_service& operator=(const offscreen_completion_service&)=delete; + ~offscreen_completion_service() { + {std::lock_guard lock(state_->mutex);state_->closing=true;} + state_->wake.notify_one(); + // The worker owns its independent state until the final completion. + // A lease may have its final release inside a native callback. + if(worker_.get_id()==std::this_thread::get_id()) worker_.detach(); + else worker_.join(); + } + void enqueue(wgpu::Future future) const { + std::lock_guard lock(state_->mutex); + if(state_->closing || state_->pending.size()>=128) + throw std::runtime_error("Offscreen GPU completion admission backpressure"); + state_->pending.push_back(future);state_->wake.notify_one(); + } +}; + +// No OS window or external-memory handle is required. Images remain on the +// application's Dawn device. This is NOT a Vulkan-to-EGL/Wayland presenter. +struct offscreen_gpu_dependencies final : webscene_gpu_producer_dependencies { + std::shared_ptr gpu; + offscreen_completion_service completion; + mutable std::atomic captures{}; + explicit offscreen_gpu_dependencies(std::shared_ptr value) + : gpu(std::move(value)),completion(gpu) {} + size_t count() const noexcept override { return 0; } + bool metal_event(size_t,void*& event,uint64_t& value) const override { + event=nullptr;value=0;return false; + } +}; +class offscreen_gpu_snapshot final : public webscene_gpu_image_snapshot { + dawn_canvas_images::submitted_frame submitted_; + std::shared_ptr dependencies_; + image_metadata metadata_; + std::shared_ptr resolved_; +public: + offscreen_gpu_snapshot(dawn_canvas_images::submitted_frame frame, + std::shared_ptr dependencies) + : submitted_(std::move(frame)), dependencies_(std::move(dependencies)), + metadata_(submitted_.image.describe()) {} + image_metadata describe() const override { return metadata_; } + status state() const override { + switch(submitted_.status->load(std::memory_order_acquire)) { + case dawn_canvas_images::submission_status::pending: return status::pending; + case dawn_canvas_images::submission_status::success: return status::ready; + default: return status::failed; + } + } + std::shared_ptr resolve() override { + if(state()!=status::ready) return {}; + if(!resolved_) resolved_=std::make_shared( + std::move(submitted_.image),dependencies_); + return resolved_; + } + // The default resolve_with_gpu_waits deliberately remains readiness-gated: + // no external semaphore handoff is advertised by this offscreen backend. +}; +struct offscreen_capture { + image_metadata metadata; + // Tightly packed RGBA8. Alpha convention follows metadata.alpha. + std::vector pixels; +}; + +// Explicit diagnostic/export readback, NEVER called by present/process_events. +// The image consumer survives until the queue has completed the copy, including +// timeout/error paths. Only the capture request may synchronously wait. +inline offscreen_capture capture_offscreen_image(const webscene_gpu_image_lease_v3& image, + uint64_t byte_limit=256ULL*1024*1024, uint64_t timeout_ns=30'000'000'000ULL) { + auto dependencies=std::dynamic_pointer_cast(image.dependencies); + if(!dependencies || image.requires_producer_wait) + throw std::invalid_argument("Capture requires a completed offscreen Dawn image"); + const auto metadata=image.value.describe(); + if(metadata.format!=image_format::bgra8_unorm && metadata.format!=image_format::rgba8_unorm) + throw std::invalid_argument("Capture currently requires BGRA8/RGBA8 unorm"); + const uint64_t row=uint64_t(metadata.width)*4; + const uint64_t stride=(row+255)&~uint64_t(255); + if(!metadata.width || !metadata.height || stride>std::numeric_limits::max() || stride>byte_limit/metadata.height) + throw std::invalid_argument("Capture exceeds the byte budget"); + struct consumer_owner { + std::optional value; + explicit consumer_owner(owned_image_pool::consumer c):value(std::move(c)) {} + ~consumer_owner() { if(value) value->complete(); } + }; + auto consumer=image.value.begin_consumer(); + if(!consumer) throw std::runtime_error("Capture consumer admission backpressure"); + auto owner=std::make_shared(std::move(*consumer)); + const auto& gpu=*dependencies->gpu; + auto texture=dawn_canvas_images::resolve(*owner->value,gpu.device); + wgpu::BufferDescriptor bd{}; + bd.size=stride*metadata.height; + bd.usage=wgpu::BufferUsage::CopyDst|wgpu::BufferUsage::MapRead; + auto buffer=gpu.device.CreateBuffer(&bd); + if(!buffer) throw std::runtime_error("Capture buffer creation failed"); + auto encoder=gpu.device.CreateCommandEncoder(); + wgpu::TexelCopyTextureInfo src{};src.texture=texture; + wgpu::TexelCopyBufferInfo dst{};dst.buffer=buffer; + dst.layout.bytesPerRow=uint32_t(stride);dst.layout.rowsPerImage=metadata.height; + wgpu::Extent3D extent{metadata.width,metadata.height,1}; + encoder.CopyTextureToBuffer(&src,&dst,&extent); + auto commands=encoder.Finish(); + auto queue=gpu.device.GetQueue(); + queue.Submit(1,&commands); + // The captured owner is intentionally released by this completion, not by + // the lifetime of the blocking caller or by a cancelled buffer mapping. + auto completed=queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [owner](wgpu::QueueWorkDoneStatus,wgpu::StringView) {}); + dependencies->completion.enqueue(completed); + struct mapped_state { bool ok=false; std::vector pixels; }; + auto state=std::make_shared(); + auto future=buffer.MapAsync(wgpu::MapMode::Read,0,bd.size,wgpu::CallbackMode::WaitAnyOnly, + [state,buffer,metadata,row,stride](wgpu::MapAsyncStatus status,wgpu::StringView) { + if(status!=wgpu::MapAsyncStatus::Success) return; + try { + const auto* data=static_cast(buffer.GetConstMappedRange()); + if(data) { + state->pixels.resize(size_t(row)*metadata.height); + for(uint32_t y=0;ypixels.data()+size_t(y)*row,data+size_t(y)*stride,size_t(row)); + if(metadata.format==image_format::bgra8_unorm) + for(size_t i=0;ipixels.size();i+=4) std::swap(state->pixels[i],state->pixels[i+2]); + state->ok=true; + } + } catch(...) { state->ok=false; } + buffer.Unmap(); + }); + const auto wait=gpu.instance.WaitAny(future,timeout_ns); + if(wait!=wgpu::WaitStatus::Success) { + buffer.Unmap(); + // Service cancellation when available; the queue-completion callback + // independently protects the image even when this wait times out. + gpu.instance.WaitAny(future,0); + throw std::runtime_error("Offscreen capture mapping timed out or was cancelled"); + } + if(!state->ok || gpu.failed->load()) throw std::runtime_error("Offscreen capture failed"); + dependencies->captures.fetch_add(1); + return {metadata,std::move(state->pixels)}; +} + +class native_webgpu_offscreen_surface final { + const std::thread::id thread_=std::this_thread::get_id(); + std::shared_ptr gpu_; + std::shared_ptr dependencies_; + dawn_canvas_images images_; + uint64_t canvas_,generation_=1,serial_=0; + uint32_t width_,height_; + std::optional current_; + static bool software_requested() { + const auto* value=std::getenv("WEBSCENE_HEADLESS_FORCE_SOFTWARE_ADAPTER"); + return value && std::string_view(value)=="1"; + } + static wgpu::BackendType backend() { +#if defined(__APPLE__) + return wgpu::BackendType::Metal; +#elif defined(_WIN32) + return wgpu::BackendType::D3D12; +#else + return wgpu::BackendType::Vulkan; +#endif + } + static std::shared_ptr create_gpu() { + // Dawn's forceFallbackAdapter currently recognizes only SwiftShader, + // not other software Vulkan ICDs such as Mesa Lavapipe. The caller + // selects its ICD; verify the returned adapter rather than mislabeling + // a hardware/default adapter or silently rejecting a software driver. + auto gpu=std::make_shared( + native_webgpu_device::create(backend(),{wgpu::FeatureName::ImplicitDeviceSynchronization})); + wgpu::AdapterInfo info{}; + if(gpu->adapter.GetInfo(&info)!=wgpu::Status::Success) + throw std::runtime_error("Cannot inspect offscreen GPU adapter"); + const bool hardware=info.adapterType==wgpu::AdapterType::DiscreteGPU || + info.adapterType==wgpu::AdapterType::IntegratedGPU; + if(software_requested() && info.adapterType!=wgpu::AdapterType::CPU) + throw std::runtime_error("Software GPU requested: select a CPU Vulkan ICD with VK_ICD_FILENAMES"); + if(!hardware && !software_requested()) + throw std::runtime_error("Software GPU requires WEBSCENE_HEADLESS_FORCE_SOFTWARE_ADAPTER=1"); + return gpu; + } + void check_thread() const { + if(std::this_thread::get_id()!=thread_) throw std::logic_error("Offscreen surface requires its owner thread"); + } + std::optional retire() { + if(!current_) return {}; + wgpu::Future future; + auto value=images_.retire_submitted(std::move(*current_),{},&future); + current_.reset();dependencies_->completion.enqueue(future);return value; + } +public: + native_webgpu_offscreen_surface(uint64_t canvas,uint32_t width,uint32_t height, + uint64_t byte_budget=64ULL*1024*1024) + :gpu_(create_gpu()),dependencies_(std::make_shared(gpu_)), + images_(gpu_->device,byte_budget),canvas_(canvas),width_(width),height_(height) { + if(!canvas) throw std::invalid_argument("Offscreen surface requires a nonzero canvas identity"); + } + native_webgpu_offscreen_surface(const native_webgpu_offscreen_surface&)=delete; + native_webgpu_offscreen_surface& operator=(const native_webgpu_offscreen_surface&)=delete; + ~native_webgpu_offscreen_surface() { check_thread();retire(); } + const wgpu::Device& device() const { return gpu_->device; } + const wgpu::Adapter& adapter() const { return gpu_->adapter; } + wgpu::Texture current_texture() { + check_thread(); + if(failed()) throw std::runtime_error("Offscreen GPU device lost"); + if(!current_) { + image_metadata m;m.canvas=canvas_;m.allocation_generation=generation_; + m.content_serial=++serial_;m.producer_timeline=canvas_;m.producer_value=serial_; + m.width=width_;m.height=height_;m.format=image_format::bgra8_unorm; + auto acquired=images_.acquire(m); + if(acquired) current_.emplace(std::move(*acquired)); + } + return current_?current_->texture:wgpu::Texture{}; + } + void resize(uint32_t width,uint32_t height) { + check_thread();if(width==width_ && height==height_) return; + retire();width_=width;height_=height;++generation_; + } + std::shared_ptr present() { + check_thread();auto submitted=retire(); + return submitted?std::make_shared(std::move(*submitted),dependencies_):nullptr; + } + void process_events() { check_thread();gpu_->instance.ProcessEvents(); } + bool failed() const { return gpu_->failed->load(); } + uint64_t resident_bytes() const { return images_.resident_bytes(); } + uint64_t created_images() const { return images_.created_images(); } + size_t busy_images() const { return images_.busy_images(); } + uint64_t capture_count() const { return dependencies_->captures.load(); } +}; +} // namespace webscene::graphics diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h index 3a00c6ee4..4cd98964b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/native_webgpu_surface.h @@ -2,7 +2,12 @@ #include "native_webgpu_device.h" #include "native_webgpu_canvas_context.h" #include "platform_webgpu_canvas.h" -#if defined(__APPLE__) || defined(_WIN32) +#if defined(__linux__) || defined(WEBSCENE_NATIVE_WEBGPU_OFFSCREEN) +#include "native_webgpu_offscreen_surface.h" +namespace webscene::graphics { +using native_webgpu_surface = native_webgpu_offscreen_surface; +} +#elif defined(__APPLE__) || defined(_WIN32) namespace webscene::graphics { // Native application surface using the same imported images and retained // snapshots as the JS binding. Hosts consume snapshots through their compositor; diff --git a/src/WebScene.NativeWeb/src/native_web.cpp b/src/WebScene.NativeWeb/src/native_web.cpp index 4e35b2258..263a8c24d 100644 --- a/src/WebScene.NativeWeb/src/native_web.cpp +++ b/src/WebScene.NativeWeb/src/native_web.cpp @@ -936,8 +936,12 @@ const scene &document::render(float width, float height) { ++order; } } - std::ranges::sort(declarations, {}, [](const auto &d) { - return std::tuple(d.important, d.specificity, d.order); + // Explicit iterator comparator avoids a Clang 18 concepts-instantiation + // crash for a projected ranges::sort inside this recursive generic lambda. + // Keep the same importance/specificity/source-order cascade key. + std::sort(declarations.begin(), declarations.end(), [](const candidate &a, const candidate &b) { + return std::tie(a.important, a.specificity, a.order) < + std::tie(b.important, b.specificity, b.order); }); specified_variables local; for (const auto &d : declarations) diff --git a/src/WebScene.Sdk/CMakeLists.txt b/src/WebScene.Sdk/CMakeLists.txt index e6f00b1c9..d788850fd 100644 --- a/src/WebScene.Sdk/CMakeLists.txt +++ b/src/WebScene.Sdk/CMakeLists.txt @@ -1,8 +1,8 @@ cmake_minimum_required(VERSION 3.28) project(WebSceneSDK VERSION 0.1.0 LANGUAGES C CXX) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/WebSceneLinuxSDK.cmake") - return() + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/WebSceneLinuxSDK.cmake") + return() endif() enable_language(OBJCXX) if(NOT APPLE OR NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64") diff --git a/src/WebScene.Sdk/cmake/LinuxSDK.cmake b/src/WebScene.Sdk/cmake/LinuxSDK.cmake new file mode 100644 index 000000000..f6adcfc7c --- /dev/null +++ b/src/WebScene.Sdk/cmake/LinuxSDK.cmake @@ -0,0 +1,50 @@ +# Native-only Linux SDK: no V8, runtime HTML loader, or JavaScript payloads. +if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + message(FATAL_ERROR "The Linux SDK currently targets x86_64") +endif() +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(WEBSCENE_NATIVE_WEB_SDK_BUILD ON) +set(BUILD_TESTING OFF CACHE BOOL "Test installed consumers separately" FORCE) +get_filename_component(WEBSCENE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +find_package(Threads REQUIRED) +option(WEBSCENE_SDK_ENABLE_WEBGPU "Include the pinned Dawn Vulkan backend" ON) +add_subdirectory("${WEBSCENE_ROOT}/src/WebScene.NativeWeb" native-web) +foreach(target webscene_core webscene_native_web webscene_native_web_shared_css) + target_compile_options(${target} PRIVATE -ffunction-sections -fdata-sections) +endforeach() +target_link_options(webscene_native_web INTERFACE "LINKER:--gc-sections") +target_link_libraries(webscene-uic PRIVATE Threads::Threads ${CMAKE_DL_LIBS} m) +target_link_libraries(webscene_native_web_shared_css PRIVATE Threads::Threads ${CMAKE_DL_LIBS} m) +add_library(WebScene::Core ALIAS webscene_core) +add_library(WebScene::NativeWeb ALIAS webscene_native_web) +add_library(WebScene::SharedCSS ALIAS webscene_native_web_shared_css) +add_executable(WebScene::Compiler ALIAS webscene-uic) +install(TARGETS webscene_core webscene_native_web webscene_native_web_shared_css ARCHIVE DESTINATION lib) +install(TARGETS webscene-uic RUNTIME DESTINATION bin) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/native-web/parser/release/libwebscene_html_parser.a" DESTINATION lib) +find_package(Python3 REQUIRED COMPONENTS Interpreter) +execute_process(COMMAND "${Python3_EXECUTABLE}" "${WEBSCENE_ROOT}/eng/sdk/header-closure.py" "${WEBSCENE_ROOT}" + OUTPUT_FILE "${CMAKE_CURRENT_BINARY_DIR}/sdk-headers.cmake" COMMAND_ERROR_IS_FATAL ANY) +include("${CMAKE_CURRENT_BINARY_DIR}/sdk-headers.cmake") +if(WEBSCENE_SDK_ENABLE_WEBGPU) + set(WEBSCENE_GRAPHICS_COMPONENTS dawn) + include("${WEBSCENE_ROOT}/eng/graphics/GraphicsDependencies.cmake") + add_library(webscene_sdk_webgpu INTERFACE) + target_include_directories(webscene_sdk_webgpu INTERFACE "${WEBSCENE_ROOT}/experiments/WebScene.NativeEngine.Probe/native/graphics") + target_link_libraries(webscene_sdk_webgpu INTERFACE webscene_core dawn::webgpu_dawn) + add_library(WebScene::WebGPU ALIAS webscene_sdk_webgpu) + install(DIRECTORY "${WEBSCENE_GRAPHICS_SDK_ROOT}/dawn/include/" DESTINATION include) + install(FILES "${WEBSCENE_GRAPHICS_SDK_ROOT}/dawn/lib/libwebgpu_dawn.so" DESTINATION lib) + install(DIRECTORY "${WEBSCENE_GRAPHICS_SDK_ROOT}/dawn/licenses/" DESTINATION share/licenses/WebScene/Dawn) + install(FILES "${WEBSCENE_GRAPHICS_SDK_ROOT}/dawn/webscene-graphics-package.json" + "${WEBSCENE_ROOT}/eng/graphics/dependencies.lock.json" DESTINATION share/webscene/dependencies) +endif() +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/WebSceneLinuxBuild.cmake" + "set(WebScene_SDK_COMPILER_ID \"${CMAKE_CXX_COMPILER_ID}\")\nset(WebScene_SDK_COMPILER_VERSION \"${CMAKE_CXX_COMPILER_VERSION}\")\nset(WebScene_SDK_WEBGPU ${WEBSCENE_SDK_ENABLE_WEBGPU})\n") +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/WebSceneLinuxBuild.cmake" DESTINATION lib/cmake/WebScene) +install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/cmake/" DESTINATION lib/cmake/WebScene) +include(CMakePackageConfigHelpers) +write_basic_package_version_file("${CMAKE_CURRENT_BINARY_DIR}/WebSceneConfigVersion.cmake" + VERSION ${PROJECT_VERSION} COMPATIBILITY SameMinorVersion) +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/WebSceneConfigVersion.cmake" DESTINATION lib/cmake/WebScene) diff --git a/src/WebScene.Sdk/cmake/WebSceneConfig.cmake b/src/WebScene.Sdk/cmake/WebSceneConfig.cmake index 6af3a637f..bd3e3287e 100644 --- a/src/WebScene.Sdk/cmake/WebSceneConfig.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneConfig.cmake @@ -1,3 +1,7 @@ +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + include("${CMAKE_CURRENT_LIST_DIR}/WebSceneLinuxConfig.cmake") + return() +endif() include_guard(GLOBAL) if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/WebSceneLinuxProfile.cmake") include("${CMAKE_CURRENT_LIST_DIR}/WebSceneLinuxConfig.cmake") diff --git a/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake b/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake index 01b8d25d5..a4799d58f 100644 --- a/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake +++ b/src/WebScene.Sdk/cmake/WebSceneLinuxConfig.cmake @@ -22,26 +22,31 @@ function(_ws_linux_static name file) INTERFACE_COMPILE_OPTIONS "-stdlib=libc++" INTERFACE_LINK_OPTIONS "-stdlib=libc++") endfunction() -_ws_linux_static(Core libwebscene_core.a) -_ws_linux_static(NativeWeb libwebscene_native_web.a) -_ws_linux_static(_Parser libwebscene_html_parser.a) -_ws_linux_static(SharedCSS libwebscene_native_web_shared_css.a) -set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::Core;Threads::Threads;${CMAKE_DL_LIBS}") -set_property(TARGET WebScene::SharedCSS PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::NativeWeb;WebScene::_Parser;Threads::Threads;${CMAKE_DL_LIBS}") -if(WebScene_SDK_WEBGPU) - if(NOT EXISTS "${WebScene_SDK_ROOT}/lib/libwebgpu_dawn.so") - message(FATAL_ERROR "Incomplete Linux SDK: missing pinned Dawn library") +if(NOT TARGET WebScene::Core) + _ws_linux_static(Core libwebscene_core.a) + _ws_linux_static(NativeWeb libwebscene_native_web.a) + _ws_linux_static(_Parser libwebscene_html_parser.a) + _ws_linux_static(SharedCSS libwebscene_native_web_shared_css.a) + set_property(TARGET WebScene::NativeWeb PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::Core;Threads::Threads;${CMAKE_DL_LIBS}") + set_property(TARGET WebScene::SharedCSS PROPERTY INTERFACE_LINK_LIBRARIES "WebScene::NativeWeb;WebScene::_Parser;Threads::Threads;${CMAKE_DL_LIBS}") + if(WebScene_SDK_WEBGPU) + if(NOT EXISTS "${WebScene_SDK_ROOT}/lib/libwebgpu_dawn.so") + message(FATAL_ERROR "Incomplete Linux SDK: missing pinned Dawn library") + endif() + add_library(WebScene::_Dawn SHARED IMPORTED GLOBAL) + set_target_properties(WebScene::_Dawn PROPERTIES IMPORTED_LOCATION "${WebScene_SDK_ROOT}/lib/libwebgpu_dawn.so" + INTERFACE_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include") + add_library(WebScene::WebGPU INTERFACE IMPORTED GLOBAL) + set_target_properties(WebScene::WebGPU PROPERTIES + INTERFACE_LINK_LIBRARIES "WebScene::Core;WebScene::_Dawn;Threads::Threads;${CMAKE_DL_LIBS}" + INTERFACE_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include/graphics") endif() - add_library(WebScene::_Dawn SHARED IMPORTED GLOBAL) - set_target_properties(WebScene::_Dawn PROPERTIES IMPORTED_LOCATION "${WebScene_SDK_ROOT}/lib/libwebgpu_dawn.so" - INTERFACE_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include") - add_library(WebScene::WebGPU INTERFACE IMPORTED GLOBAL) - set_target_properties(WebScene::WebGPU PROPERTIES - INTERFACE_LINK_LIBRARIES "WebScene::Core;WebScene::_Dawn;Threads::Threads;${CMAKE_DL_LIBS}" - INTERFACE_INCLUDE_DIRECTORIES "${WebScene_SDK_ROOT}/include/graphics") + add_executable(WebScene::Compiler IMPORTED GLOBAL) + set_property(TARGET WebScene::Compiler PROPERTY IMPORTED_LOCATION "${WebScene_SDK_ROOT}/bin/webscene-uic") endif() -add_executable(WebScene::Compiler IMPORTED GLOBAL) -set_property(TARGET WebScene::Compiler PROPERTY IMPORTED_LOCATION "${WebScene_SDK_ROOT}/bin/webscene-uic") +include("${CMAKE_CURRENT_LIST_DIR}/WebSceneApplication.cmake") +set(WebScene_FOUND TRUE) +unset(WebScene_NOT_FOUND_MESSAGE) foreach(component IN LISTS WebScene_FIND_COMPONENTS) if(TARGET WebScene::${component}) set(WebScene_${component}_FOUND TRUE) @@ -49,9 +54,7 @@ foreach(component IN LISTS WebScene_FIND_COMPONENTS) set(WebScene_${component}_FOUND FALSE) if(WebScene_FIND_REQUIRED_${component}) set(WebScene_FOUND FALSE) - set(WebScene_NOT_FOUND_MESSAGE "Component ${component} is not installed in this native-only Linux SDK") - return() + string(APPEND WebScene_NOT_FOUND_MESSAGE "Component ${component} is not installed in this native-only Linux SDK. ") endif() endif() endforeach() -include("${CMAKE_CURRENT_LIST_DIR}/WebSceneApplication.cmake") diff --git a/tests/SDKLinux/CMakeLists.txt b/tests/SDKLinux/CMakeLists.txt new file mode 100644 index 000000000..653138f32 --- /dev/null +++ b/tests/SDKLinux/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.28) +project(WebSceneLinuxSDKContracts LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) +find_package(WebScene REQUIRED CONFIG COMPONENTS NativeWeb SharedCSS Compiler) +include(CTest) +option(WEBSCENE_TEST_WEBGPU "Require and exercise real offscreen WebGPU" ON) +if(WEBSCENE_TEST_WEBGPU) + find_package(WebScene REQUIRED CONFIG COMPONENTS WebGPU) + add_executable(offscreen_gpu offscreen_gpu.cpp) + target_link_libraries(offscreen_gpu PRIVATE WebScene::WebGPU) + add_test(NAME offscreen_gpu COMMAND offscreen_gpu) + set_tests_properties(offscreen_gpu PROPERTIES TIMEOUT 90) +endif() +add_executable(compiled_native compiled_native.cpp) +webscene_compile_html(compiled_native Main.html MODULE linux.contract.ui CSS_BACKEND shared) +target_link_libraries(compiled_native PRIVATE WebScene::NativeWeb WebScene::SharedCSS) +add_test(NAME compiled_native COMMAND compiled_native) + +find_package(Python3 REQUIRED COMPONENTS Interpreter) +add_test(NAME installed_package_components + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/package_components.py" + --sdk "${WebScene_SDK_ROOT}" --compiler "${CMAKE_CXX_COMPILER}") +set_tests_properties(installed_package_components PROPERTIES TIMEOUT 90) diff --git a/tests/SDKLinux/Main.html b/tests/SDKLinux/Main.html new file mode 100644 index 000000000..31eb22df9 --- /dev/null +++ b/tests/SDKLinux/Main.html @@ -0,0 +1,7 @@ +

0

+
diff --git a/tests/SDKLinux/compiled_native.cpp b/tests/SDKLinux/compiled_native.cpp new file mode 100644 index 000000000..b637e34c9 --- /dev/null +++ b/tests/SDKLinux/compiled_native.cpp @@ -0,0 +1,23 @@ +#include +#include +#include +import linux.contract.ui; +void require(bool value,const char* message){if(!value)throw std::runtime_error(message);} +int main() { + using namespace webscene::native_web; + document doc;compiled_ui::build(doc);int count=0; + auto subscription=doc.on(doc.find("increment"),"click",[&](auto&){doc.set_text(doc.find("count"),std::to_string(++count));}); + doc.render(640,480);auto bounds=doc.bounds(doc.find("increment")); + require(bounds.width==160 && bounds.height==40,"shared CSS button bounds"); + doc.pointer("pointerdown",bounds.x+10,bounds.y+10,1); + doc.pointer("pointerup",bounds.x+10,bounds.y+10,0); + require(count==1 && doc.text_content(doc.find("count"))=="1","native click delivery"); + doc.focus(doc.find("name"));doc.text_input("Kestrel \xc3\xa9"); + require(doc.value(doc.find("name"))=="Kestrel \xc3\xa9","native committed UTF-8 text"); + doc.key("Backspace");require(doc.value(doc.find("name"))=="Kestrel ","native Unicode backspace"); + doc.attribute(doc.find("increment"),"style","width:240px");doc.render(800,600); + require(doc.bounds(doc.find("increment")).width==240,"dynamic shared CSS"); + doc.set_dark_color_scheme(true);doc.render(320,240); + subscription={};doc.dispose();require(doc.disposed(),"native document shutdown"); + std::cout<<"{\"compiledHtml\":true,\"sharedCSS\":true,\"nativeEvents\":true,\"javascript\":false}\n"; +} diff --git a/tests/SDKLinux/offscreen_gpu.cpp b/tests/SDKLinux/offscreen_gpu.cpp new file mode 100644 index 000000000..1dd3b7a8d --- /dev/null +++ b/tests/SDKLinux/offscreen_gpu.cpp @@ -0,0 +1,90 @@ +#include +#include +#include +#include +#include +#include +using namespace webscene::graphics; +void require(bool value,const char* message){if(!value)throw std::runtime_error(message);} +auto paint(native_webgpu_surface& surface,double red,double green,double blue) { + auto texture=surface.current_texture();require(bool(texture),"surface admission unexpectedly failed"); + require(surface.current_texture().Get()==texture.Get(),"current texture changed inside a frame"); + wgpu::RenderPassColorAttachment color{};color.view=texture.CreateView(); + color.loadOp=wgpu::LoadOp::Clear;color.storeOp=wgpu::StoreOp::Store;color.clearValue={red,green,blue,1}; + wgpu::RenderPassDescriptor pass{};pass.colorAttachmentCount=1;pass.colorAttachments=&color; + auto encoder=surface.device().CreateCommandEncoder();auto render=encoder.BeginRenderPass(&pass);render.End(); + auto command=encoder.Finish();surface.device().GetQueue().Submit(1,&command); + auto snapshot=surface.present();require(bool(snapshot),"snapshot admission unexpectedly failed");return snapshot; +} +auto resolve(native_webgpu_surface& surface,std::shared_ptr snapshot) { + const auto end=std::chrono::steady_clock::now()+std::chrono::seconds(20); + while(snapshot->state()==webscene_gpu_image_snapshot::status::pending && std::chrono::steady_clock::now()state()==webscene_gpu_image_snapshot::status::ready,"GPU submission failed or timed out"); + auto image=snapshot->resolve();require(bool(image),"completed snapshot did not resolve");return image; +} +void pixel(const offscreen_capture& image,int red,int green,int blue) { + require(image.pixels.size()==size_t(image.metadata.width)*image.metadata.height*4,"non-tight row readback"); + for(size_t i=0;i surviving; + uint64_t captures=0,allocations=0; + bool hardware=false; + { + native_webgpu_surface surface(7,17,4,64*1024); + wgpu::AdapterInfo info{};require(surface.adapter().GetInfo(&info)==wgpu::Status::Success,"adapter identity unavailable"); + hardware=info.adapterType==wgpu::AdapterType::DiscreteGPU || info.adapterType==wgpu::AdapterType::IntegratedGPU; + require(info.backendType==wgpu::BackendType::Vulkan,"Linux test did not run Vulkan"); + // GPU completion must retire an abandoned snapshot while application + // frames are paused. No ProcessEvents or render timer drives this wait. + { + auto abandoned=paint(surface,1,0,0);abandoned.reset(); + const auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(20); + while(surface.busy_images() && std::chrono::steady_clock::now()value.describe().allocation_generation; + green.reset();blue.reset(); + surface.resize(65,7); + auto resized=resolve(surface,paint(surface,0,1,0)); + require(resized->value.describe().allocation_generation>generation,"resize did not advance generation"); + require(resized->value.describe().width==65 && resized->value.describe().height==7,"resized metadata mismatch"); + pixel(capture_offscreen_image(*resized),0,255,0); + pixel(capture_offscreen_image(*red),255,0,0); + require(surface.resident_bytes()<=64*1024,"resident allocation budget exceeded"); + bool wrong_thread=false; + std::thread foreign([&]{try{surface.resize(2,2);}catch(const std::logic_error&){wrong_thread=true;}});foreign.join(); + require(wrong_thread,"surface accepted mutation from a foreign thread"); + { + native_webgpu_surface other(8,2,2); + auto consumer=red->value.begin_consumer();require(bool(consumer),"consumer unavailable"); + bool rejected=false; + try{(void)dawn_canvas_images::resolve(*consumer,other.device());}catch(const std::invalid_argument&){rejected=true;} + consumer->complete();require(rejected,"cross-device image accepted"); + } + bool budget_rejected=false; + try{(void)capture_offscreen_image(*red,1);}catch(const std::invalid_argument&){budget_rejected=true;} + require(budget_rejected,"capture ignored byte budget"); + captures=surface.capture_count();allocations=surface.created_images();surviving=std::move(red); + } + pixel(capture_offscreen_image(*surviving),255,0,0); + surviving.reset(); + for(int i=0;i<3;++i) { + native_webgpu_surface surface(10+i,13,5); + auto image=resolve(surface,paint(surface,0,1,0));pixel(capture_offscreen_image(*image),0,255,0); + } + std::cout<<"{\"backend\":\"Vulkan\",\"hardware\":"<<(hardware?"true":"false") + <<",\"readbacksBeforeCapture\":0,\"explicitCaptures\":"<