From 9c1eb0e422c6ad5c9e9d35b1b2579dafed81d2de Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 29 Aug 2026 23:08:35 +0300 Subject: [PATCH 1/2] Add rendering frame-rate diagnostics and bump Nucleus to 2.5.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #57 asks whether the Tao desktop backend renders offscreen or caps the frame rate, and whether a max_fps knob could be exposed. It does not: every desktop backend embeds a real native view (WKWebView NSView under the Compose Metal layer, reparented WebKit2GTK widget, WebView2 DComp visual) and no backend contains frame-rate logic. Measured on macOS/M4 with a 90 Hz display, embedded vs. a bare WKWebView in a plain NSWindow: 90 vs 90 fps for canvas/WebGL, 31-34 vs 32-35 fps on a GPU-bound shader — parity in both cases. - e2e-shared: new Rendering group in the shared catalog. R01 reports the requestAnimationFrame rate, R02 the WebGL renderer (a software renderer explains slow WebGL better than any fps number). R01 is Skipped when the document is hidden: every engine suspends rAF for an occluded window, so the sample would read 0 fps and say nothing about the backend. - e2e-shared: runMeasuredCase / SuiteSkip so a case can publish its measurement as the reported detail, and the suite UI + console keep showing details for passed cases. - Bump Nucleus 2.3.1 -> 2.5.5 (the version this library api-exports, so consumers inherit it). - README: document the rendering model, the absence of any throttle or max_fps knob, and how to measure it locally. --- README.md | 50 ++++++++++++++++ .../webview/e2e/visualsuite/SuiteCatalog.kt | 5 ++ .../webview/e2e/visualsuite/SuiteHelpers.kt | 27 +++++++++ .../webview/e2e/visualsuite/SuitePages.kt | 59 +++++++++++++++++++ .../webview/e2e/visualsuite/SuiteRunner.kt | 48 +++++++++++++++ .../webview/e2e/visualsuite/VisualSuiteApp.kt | 8 ++- gradle/libs.versions.toml | 2 +- 7 files changed, 196 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index abb3e12..2588ea8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,56 @@ If you already know **compose-webview-multiplatform**, you already know how to u --- +## Rendering model & frame rate + +The desktop backend embeds a **real native view** — it does **not** render the page +offscreen into a bitmap and blit it into the Compose scene: + +- **macOS**: the `WKWebView` `NSView` is a subview of the Tao window, below the Compose + Metal layer; Compose punches a transparent hole over the WebView rect. +- **Linux**: the WebKit2GTK widget is reparented into Tao's content widget. +- **Windows**: WebView2 runs as a DirectComposition visual composited by DWM. + +Consequences: + +- There is **no frame pacing, throttling or `max_fps` knob** in this library — none of + the backends contain frame-rate logic. The page paints at whatever rate the platform + compositor gives it, which is normally the **display refresh rate**. +- The WebView's own frames do not go through Compose. Compose renders its overlay in + the same window, so a heavy Compose UI shares the GPU with the page, but it never + gates the WebView's frames. + +### Measure it on your hardware + +`./gradlew :e2e-desktop:run` reports two rendering measurements (they publish numbers, +they do not enforce thresholds): + +```text +Passed R01 Rendering requestAnimationFrame rate 90 fps +Passed R02 Rendering WebGL renderer Apple GPU +``` + +A healthy `R01` is the refresh rate of the display the window is on, and `R02` should +name a GPU (a software renderer there is the usual reason WebGL content is slow). + +`R01` is **Skipped** when the document reports `visibilityState = "hidden"`: every engine +suspends `requestAnimationFrame` for a window that is fully covered or backgrounded, so +the sample would read 0 fps and say nothing about the backend. A bare `WKWebView` in a +plain `NSWindow` behaves exactly the same — keep the window in front while measuring. + +Reference measurement (macOS, M4, 90 Hz display, Nucleus Tao 2.5.5, `rAF` + WebGL page) +— embedded WebView vs. the same page in a bare `WKWebView` in a plain `NSWindow`: + +| Workload | Embedded (Tao `NativeView`) | Bare `WKWebView` | +|----------|-----------------------------|------------------| +| Canvas 2D animation | 90 fps | 90 fps | +| WebGL, GPU-bound shader | 31–34 fps | 32–35 fps | + +When reporting a frame-rate problem, include the `R01`/`R02` values, the display refresh +rate, and whether Compose content overlaps the WebView. + +--- + ## Quick start ```kotlin diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt index 75e7515..a0f3fbe 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt @@ -84,4 +84,9 @@ internal fun suiteCatalog(): List = SuiteCase("L07", "Lifecycle", "can recover after Rejected navigation"), SuiteCase("L08", "Lifecycle", "isolated destroy() tears down cleanly"), SuiteCase("L09", "Lifecycle", "headers load then HTML recovery keeps API live"), + // Rendering — measurements, not thresholds: the backend embeds a real + // native WebView and never throttles it, so these report what the host + // actually achieves (see README "Rendering model & frame rate"). + SuiteCase("R01", "Rendering", "requestAnimationFrame rate"), + SuiteCase("R02", "Rendering", "WebGL renderer"), ) diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteHelpers.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteHelpers.kt index 5c18d4f..45002eb 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteHelpers.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteHelpers.kt @@ -102,6 +102,33 @@ internal suspend fun runCase( } } +/** Thrown by a case that cannot be measured on this host right now. */ +internal class SuiteSkip( + message: String, +) : Exception(message) + +internal fun skipCase(reason: String): Nothing = throw SuiteSkip(reason) + +/** + * Like [runCase], but the block returns the measurement to publish as the case + * detail (reported instead of the default "ok") — for cases whose value is the + * number they produce, not a pass/fail threshold. A [SuiteSkip] marks the case + * Skipped instead of Failed. + */ +internal suspend fun runMeasuredCase( + onStatus: (CaseStatus, String) -> Unit, + block: suspend () -> String, +) { + onStatus(CaseStatus.Running, "") + try { + onStatus(CaseStatus.Passed, block()) + } catch (skip: SuiteSkip) { + onStatus(CaseStatus.Skipped, skip.message ?: "not measurable") + } catch (t: Throwable) { + onStatus(CaseStatus.Failed, t.message ?: t::class.simpleName ?: "error") + } +} + internal suspend fun softTimeout( timeoutMs: Long, block: suspend () -> Unit, diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt index 846804d..3902b8b 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuitePages.kt @@ -48,6 +48,65 @@ internal fun pageSolidColor(hex: String): String = """.trimIndent() +/** + * Animates a box on every `requestAnimationFrame` and publishes the frame rate + * of the last full second in `window.__fps` (0 until the first second elapsed). + * The transform keeps real compositing work in the loop, so a throttled + * compositor shows up in the number instead of a free-running empty callback. + */ +internal fun pageFrameRate(): String = + """ + FrameRate + +
raf-probe
+ + """.trimIndent() + +/** + * Creates a WebGL context and publishes its renderer in `window.__glRenderer` + * ("unavailable" when the host has no WebGL). A software renderer here explains + * slow WebGL content far better than any frame-rate number. + */ +internal fun pageWebGl(): String = + """ + WebGL + +
webgl-probe
+ + """.trimIndent() + internal fun pageWithInitProbe(): String = """ InitProbe diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt index 94607fd..bf584de 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt @@ -33,6 +33,20 @@ internal suspend fun runFullSuite( runCase(onStatus = { s, d -> onCase(id, s, d) }) { block() } } + /** Case whose reported detail is the measurement it returns. */ + suspend fun measured( + id: String, + required: Set = emptySet(), + block: suspend () -> String, + ) { + val missing = required - caps + if (missing.isNotEmpty()) { + onCase(id, CaseStatus.Skipped, "unsupported: ${missing.joinToString(",")}") + return + } + runMeasuredCase(onStatus = { s, d -> onCase(id, s, d) }) { block() } + } + waitWebView(ctx.state) delay(300) @@ -676,4 +690,38 @@ internal suspend fun runFullSuite( val r = evalJs(ctx.navigator, "1+1") assertThat(r.contains("2"), "API dead after headers path: $r") } + + // ── Rendering ──────────────────────────────────────────────────── + // The WebView is a real native view (no offscreen rendering, no frame + // pacing in this library), so these publish what the host reaches — a + // healthy value is the display refresh rate. They only fail when the + // page is not animating at all. + measured("R01") { + loadHtmlAwaitMarker(ctx.navigator, "raf-probe", pageFrameRate()) + // First second primes window.__fps, the second one is the sample. + delay(2_200) + // Every engine suspends requestAnimationFrame for a hidden document — + // a window covered by another one measures 0 fps and says nothing about + // the backend, so skip instead of failing (CI runs windows unattended). + val visibility = evalJsUnquoted(ctx.navigator, "document.visibilityState") + if (visibility != "visible") { + skipCase("document is $visibility (window occluded/backgrounded)") + } + val fps = evalJsUnquoted(ctx.navigator, "String(window.__fps || 0)").toIntOrNull() ?: 0 + assertThat(fps >= MIN_ANIMATING_FPS, "requestAnimationFrame stalled at $fps fps") + "$fps fps" + } + measured("R02") { + loadHtmlAwaitMarker(ctx.navigator, "webgl-probe", pageWebGl()) + val renderer = evalJsUnquoted(ctx.navigator, "String(window.__glRenderer || '')") + assertThat(renderer.isNotBlank(), "WebGL probe did not run") + renderer + } } + +/** + * Floor for "the page is animating at all". Deliberately far below any real + * display rate: R01 reports a measurement, it does not police host performance + * (CI runners render in software). + */ +private const val MIN_ANIMATING_FPS = 10 diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt index 5725f4e..056925a 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt @@ -200,7 +200,7 @@ fun VisualSuiteApp( summary = when (status) { CaseStatus.Running -> "Running $id…" - CaseStatus.Passed -> "$id PASS" + CaseStatus.Passed -> if (detail == "ok") "$id PASS" else "$id PASS: $detail" CaseStatus.Failed -> "$id FAIL: $detail" CaseStatus.Skipped -> "$id SKIP: $detail" else -> summary @@ -341,7 +341,11 @@ fun VisualSuiteApp( fontSize = 11.sp, maxLines = 1, ) - if (c.detail.isNotBlank() && c.status != CaseStatus.Passed) { + // "ok" is the default pass detail — measurements + // (R01/R02) carry their value there and stay visible. + if (c.detail.isNotBlank() && + (c.status != CaseStatus.Passed || c.detail != "ok") + ) { Text( c.detail.take(140), color = statusColor(c.status), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c561f8..b98c04c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ google-material = "1.14.0" kotlin = "2.4.10" kotlinx-coroutines = "1.11.0" kotlinx-serialization = "1.11.0" -nucleus = "2.3.1" +nucleus = "2.5.5" [libraries] compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } From c3e60d7f9c337f731846a541959ccc846632ca27 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 29 Aug 2026 23:24:53 +0300 Subject: [PATCH 2/2] Document measured blending cost of the Compose overlay --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 2588ea8..a794dd4 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,22 @@ Reference measurement (macOS, M4, 90 Hz display, Nucleus Tao 2.5.5, `rAF` + WebG | Canvas 2D animation | 90 fps | 90 fps | | WebGL, GPU-bound shader | 31–34 fps | 32–35 fps | +Blending an overlay on top does not change that. Same page, full-screen window +(2560×1040), with an animated Compose overlay in the `content` slot — page fps / +Compose fps, plus GPU utilization sampled while both run at the display rate: + +| Compose overlay | Light page | GPU-bound page | GPU util (light page) | +|-----------------|-----------|----------------|-----------------------| +| none | 90 / 90 | 34 / 90 | 23.4 % | +| 64 dp animated bar | 90 / 90 | 34 / 90 | — | +| full-window translucent scrim | 90 / 90 | 34 / 90 | 22.9 % | +| full-window opaque surface | 90 / 90 | 34 / 90 | — | +| *bare `WKWebView`, opaque window* | *90* | *34* | *27.6 %* | + +Note that an **opaque** Compose overlay does not stop the WebView underneath: it +keeps rendering at full speed behind it, so hide or dispose it instead of covering +it if you want the GPU work back. + When reporting a frame-rate problem, include the `R01`/`R02` values, the display refresh rate, and whether Compose content overlaps the WebView.