diff --git a/README.md b/README.md index abb3e12..a794dd4 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,72 @@ 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 | + +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. + +--- + ## 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" }