Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,9 @@ internal fun suiteCatalog(): List<SuiteCase> =
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"),
)
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ internal suspend fun <T> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,65 @@ internal fun pageSolidColor(hex: String): String =
</head><body></body></html>
""".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 =
"""
<!DOCTYPE html><html><head><meta charset="utf-8"><title>FrameRate</title>
<style>html,body{margin:0;background:#0f172a;color:#e2e8f0;font-family:system-ui}
#marker{padding:12px;font-weight:700}#box{width:72px;height:72px;background:#34d399}</style>
</head><body><div id="marker">raf-probe</div><div id="box"></div>
<script>
window.__fps = 0;
var frames = 0, last = performance.now(), box = document.getElementById('box');
function loop(t) {
frames++;
box.style.transform = 'translateX(' + ((t / 6) % 240) + 'px)';
if (t - last >= 1000) {
window.__fps = Math.round(frames * 1000 / (t - last));
frames = 0;
last = t;
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script></body></html>
""".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 =
"""
<!DOCTYPE html><html><head><meta charset="utf-8"><title>WebGL</title>
<style>html,body{margin:0;background:#0f172a;color:#e2e8f0;font-family:system-ui}
#marker{padding:12px;font-weight:700}</style>
</head><body><div id="marker">webgl-probe</div><canvas id="gl" width="64" height="64"></canvas>
<script>
window.__glRenderer = 'unavailable';
try {
var c = document.getElementById('gl');
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
if (gl) {
gl.clearColor(0.1, 0.6, 0.4, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
var dbg = gl.getExtension('WEBGL_debug_renderer_info');
window.__glRenderer =
(dbg && gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) ||
gl.getParameter(gl.RENDERER) || 'webgl';
}
} catch (e) {
window.__glRenderer = 'error: ' + e;
}
</script></body></html>
""".trimIndent()

internal fun pageWithInitProbe(): String =
"""
<!DOCTYPE html><html><head><meta charset="utf-8"><title>InitProbe</title></head>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuiteCapability> = 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)

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading