mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-05 18:57:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad70f342b | ||
|
|
cf597c12e0 | ||
|
|
cbddf8ca0b | ||
|
|
ad738a643e | ||
|
|
8f5c59bd64 | ||
|
|
914bab840d | ||
|
|
8f96ba9478 | ||
|
|
5a9e7b0f7f | ||
|
|
5dfd9200f8 | ||
|
|
8c7168a1a9 | ||
|
|
377f3aa775 | ||
|
|
662975c802 | ||
|
|
3b420a7bad | ||
|
|
2dad6c9bcc | ||
|
|
723f80c8af | ||
|
|
450247175f | ||
|
|
5d6f772a1c | ||
|
|
575c19f921 | ||
|
|
328663b9e7 | ||
|
|
c9ddd3488d | ||
|
|
9958386792 | ||
|
|
f5fae6d32e | ||
|
|
b94f1f7bbb | ||
|
|
ffbb87eddd | ||
|
|
63fad4083f | ||
|
|
872d740ce7 | ||
|
|
4c2d46429d |
@@ -18,9 +18,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
# Each host runs only the publish tasks Gradle actually generated for it.
|
# Each host runs only the publish tasks Gradle actually generated for it.
|
||||||
# macOS also publishes the shared kotlinMultiplatform metadata module.
|
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
|
||||||
# No library module declares jvm() today — apps do their own JVM parity
|
# (see its matrix entry) + the jvm jar + the bridge plugin.
|
||||||
# build against upstream Compose, so JVM isn't part of the publication.
|
|
||||||
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
||||||
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
||||||
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
||||||
@@ -31,16 +30,8 @@ jobs:
|
|||||||
host: macos
|
host: macos
|
||||||
target: MacosArm64
|
target: MacosArm64
|
||||||
exe_ext: kexe
|
exe_ext: kexe
|
||||||
# jvm publication: only :material-symbols declares a jvm() target
|
|
||||||
# (its common API renders via upstream Compose on JVM) — publish
|
|
||||||
# its host-independent jar from the same host as the metadata.
|
|
||||||
# :compose-desktop-native-bridge (the consumer-side bridge Gradle plugin +
|
|
||||||
# its plugin marker) is host-independent too — same job.
|
|
||||||
tasks: >-
|
tasks: >-
|
||||||
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
|
||||||
publishMacosArm64PublicationToGitHubPackagesRepository
|
publishMacosArm64PublicationToGitHubPackagesRepository
|
||||||
publishJvmPublicationToGitHubPackagesRepository
|
|
||||||
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
|
|
||||||
- runner: ubuntu-22.04
|
- runner: ubuntu-22.04
|
||||||
host: linux
|
host: linux
|
||||||
target: LinuxX64
|
target: LinuxX64
|
||||||
@@ -52,8 +43,18 @@ jobs:
|
|||||||
host: windows
|
host: windows
|
||||||
target: MingwX64
|
target: MingwX64
|
||||||
exe_ext: exe
|
exe_ext: exe
|
||||||
|
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
|
||||||
|
# the ONLY host that declares every target (vHostSupportsMingw is
|
||||||
|
# host-gated), so only its generated .module files carry the full
|
||||||
|
# variant table — the macOS-published roots of v0.1.15 had NO
|
||||||
|
# mingwX64 variants and Windows consumers could not resolve them.
|
||||||
|
# The host-independent jvm jar (:material-symbols) and the bridge
|
||||||
|
# plugin ride along on the same job.
|
||||||
tasks: >-
|
tasks: >-
|
||||||
publishMingwX64PublicationToGitHubPackagesRepository
|
publishMingwX64PublicationToGitHubPackagesRepository
|
||||||
|
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
||||||
|
publishJvmPublicationToGitHubPackagesRepository
|
||||||
|
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
|
||||||
|
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
@@ -148,8 +149,8 @@ jobs:
|
|||||||
# already completed; you can't tell from the 409 alone. The fix: between
|
# already completed; you can't tell from the 409 alone. The fix: between
|
||||||
# attempts, DELETE this host's package versions for the current release
|
# attempts, DELETE this host's package versions for the current release
|
||||||
# and re-upload from a clean slate. Each host owns DISJOINT package
|
# and re-upload from a clean slate. Each host owns DISJOINT package
|
||||||
# names (roots / -macosarm64 / -jvm / bridge on macOS, -mingwx64 on
|
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
|
||||||
# Windows, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
|
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
|
||||||
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
|
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
|
||||||
# a 403 on delete just degrades to the old blind-retry behaviour.
|
# a 403 on delete just degrades to the old blind-retry behaviour.
|
||||||
- name: Publish to GitHub Packages
|
- name: Publish to GitHub Packages
|
||||||
@@ -161,9 +162,9 @@ jobs:
|
|||||||
# Keep in sync with the module list in settings.gradle.kts.
|
# Keep in sync with the module list in settings.gradle.kts.
|
||||||
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
|
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
|
||||||
case "${{ matrix.host }}" in
|
case "${{ matrix.host }}" in
|
||||||
macos) SUFFIXES="_ROOT_ -macosarm64" ;;
|
macos) SUFFIXES="-macosarm64" ;;
|
||||||
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
|
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
|
||||||
windows) SUFFIXES="-mingwx64" ;;
|
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
|
||||||
esac
|
esac
|
||||||
host_packages() {
|
host_packages() {
|
||||||
for m in $MODULES; do
|
for m in $MODULES; do
|
||||||
@@ -171,7 +172,7 @@ jobs:
|
|||||||
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
|
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
if [ "${{ matrix.host }}" = "macos" ]; then
|
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||||
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
|
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
|
||||||
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
|
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
|
||||||
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
|
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
|
||||||
|
|||||||
@@ -275,11 +275,15 @@ commonMain
|
|||||||
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
||||||
```
|
```
|
||||||
|
|
||||||
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are declared identically
|
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
|
||||||
in both `skikoRendererMain` and `sdlRendererMain`. `:window` calls them straight
|
`:ui`'s nativeMain with `actual`s in BOTH `skikoRendererMain` and
|
||||||
from `:ui` — no `expect`/`actual`, no factory layer — and the right impl
|
`sdlRendererMain` — unambiguous because **only one of the two renderer source
|
||||||
resolves because **only one of the two renderer source sets is attached to a
|
sets is attached to a given target**. (They used to be plain duplicate
|
||||||
given target**. Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
declarations with no expect; that compiled per-target but shared nativeMain
|
||||||
|
METADATA couldn't see them on a host whose targets span both renderers, which
|
||||||
|
blocked the WINDOWS host from producing :window's KotlinMultiplatform
|
||||||
|
publication — and Windows must publish the root modules, see the publish
|
||||||
|
workflow.) Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
||||||
**not even created**, so Gradle has nothing to warn about and Skiko is never
|
**not even created**, so Gradle has nothing to warn about and Skiko is never
|
||||||
pulled in.
|
pulled in.
|
||||||
|
|
||||||
@@ -454,6 +458,96 @@ build tell you what broke.
|
|||||||
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
||||||
ship drop-in equivalents for our anchor / scrim patterns).
|
ship drop-in equivalents for our anchor / scrim patterns).
|
||||||
|
|
||||||
|
## Tooling — what's available and when to reach for it
|
||||||
|
|
||||||
|
Index of the repo's tooling. Each entry says when to use it and points at its
|
||||||
|
own README for detail; the parity/profiler/probe entries are expanded below
|
||||||
|
since they're newer.
|
||||||
|
|
||||||
|
| Tool | Reach for it when | Detail |
|
||||||
|
|------|-------------------|--------|
|
||||||
|
| `python scripts/build-sdl/build-all.py` | building/refreshing the static SDL3/TTF/image/FreeType libs under `libs/` (once per host, or after bumping `build-sdl.properties`) | "Building" above |
|
||||||
|
| `scripts/compose-fork/sync.sh` | re-syncing vendored upstream `androidx.compose.*` after a fresh checkout or a `compose.properties` ref bump; `format-manifest.py` to re-align a `compose-fork.txt` | "Vendor sync workflow" above + `scripts/compose-fork/README.md` |
|
||||||
|
| `./gradlew apiDump && python scripts/compose-coverage.py` | measuring how much upstream public API the port actually covers, per module (`--missing <module>` lists uncovered decls) | "Vendoring" above |
|
||||||
|
| `scripts/generate-material-symbols.py` / `subset-material-symbols.py` | regenerating the Material Symbols codepoints, or hb-subsetting bundled icon fonts to used glyphs (the latter runs automatically in app Zip tasks under `-PsubsetIcons`) | — |
|
||||||
|
| **`scripts/parity/parity.py`** | after ANY renderer/layout change: catch a screen that visually diverged native-vs-JVM (missing content, wrong shape/colour, broken clip) | `scripts/parity/README.md` + below |
|
||||||
|
| **`scripts/probe/probe.py`** | reproducing a specific interaction bug (click/hover/hold at a point) or grabbing one screen's pixels deterministically | `scripts/probe/README.md` + below |
|
||||||
|
| **`CDN_PROFILE=1 <app>`** | finding where a slow frame goes (per-phase main-loop timings) before optimizing | below |
|
||||||
|
| bridge plugin (`com.bitsycore.compose-desktop-native.bridge`) | consuming the published klibs from a third-party app | `gradle-plugin/compose-desktop-native-bridge/README.md` |
|
||||||
|
| `demo --screen=<Name>` / `--screenshot=` / `--nav3test` / `--backtest` / `--multiwintest` | driving one screen headless, or the regression probes for nav3 / predictive-back / multi-window | `demo/src/nativeMain/kotlin/MainNative.kt` |
|
||||||
|
|
||||||
|
Whole-project renderer-touching change → run **parity** (broad net). Chasing
|
||||||
|
one reported interaction → **probe** (targeted). Slow → **profiler** first,
|
||||||
|
optimize second. See `ROADMAP.md` for the renderer work these support.
|
||||||
|
|
||||||
|
### Frame profiler — `CDN_PROFILE=1`
|
||||||
|
|
||||||
|
Set the env var (`CDN_PROFILE=1`, or `=<path>` for a specific file) and run any
|
||||||
|
native app; every ~2 s of rendered frames it writes avg/max ms per main-loop
|
||||||
|
phase (`events` / `app` pump / `pump` per-window / `render`) to a FILE
|
||||||
|
(`cdn_profile.log` by default — works for GUI-subsystem apps like the demo that
|
||||||
|
have no console). The line also carries render SUB-phases (`layout` / `draw` /
|
||||||
|
`present`) and per-frame DRAW COUNTERS from `DrawStats` (`geo` =
|
||||||
|
SDL_RenderGeometry submits, `verts`, `masks` = rounded-clip offscreen passes,
|
||||||
|
`text`, `img` blits) — so you can see WHAT `draw` is doing, not just that it's
|
||||||
|
slow. `CDN_FORCERENDER=1` renders every frame (bypasses idle-skip) so
|
||||||
|
steady-state timings can be read on otherwise-idle static screens.
|
||||||
|
|
||||||
|
**Caveat learned the hard way:** `present` is vsync-blocking, so every timing is
|
||||||
|
capped by the DISPLAY refresh of the machine running the app. A 75 Hz dev
|
||||||
|
display makes every app look "75 fps, present-bound" no matter what — so profile
|
||||||
|
on the TARGET refresh rate before concluding anything about a frame-rate gap
|
||||||
|
(the demo-70 / apidemo-144 report could not be reproduced on a 75 Hz box, where
|
||||||
|
both pinned at 75 fps and the demo's `draw` was actually the lighter of the two).
|
||||||
|
|
||||||
|
### Interaction probe — `scripts/probe/`
|
||||||
|
|
||||||
|
Launches a native app, sends **window-client-relative** input (click / hover /
|
||||||
|
hold, fractional coords addressed by process name so it ignores window
|
||||||
|
position/focus) and captures the client area via `PrintWindow` (works even
|
||||||
|
when occluded). The packaged form of the rigs that reproduced the
|
||||||
|
square-on-click and TLS-chain bugs. Windows-only. See its README.
|
||||||
|
|
||||||
|
### Parity harness — native-vs-JVM screenshot diff (`scripts/parity/`)
|
||||||
|
|
||||||
|
`:demo` renders the **same commonMain screens** on two stacks: native
|
||||||
|
(SDL/Skia, Kotlin/Native) and a `jvm()` target on upstream Compose Desktop.
|
||||||
|
`scripts/parity/parity.py` screenshots every screen on both and pixel-diffs
|
||||||
|
them, so a screen that visually diverges is a **port regression** (missing
|
||||||
|
content, wrong shape/colour, broken clip). Several past renderer regressions
|
||||||
|
would have been caught here.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/parity/parity.py # all screens (builds first)
|
||||||
|
python scripts/parity/parity.py Buttons Shapes # a subset
|
||||||
|
python scripts/parity/parity.py --no-build # reuse the last renders
|
||||||
|
```
|
||||||
|
|
||||||
|
Mechanics: the JVM leg renders all screens headlessly via `ImageComposeScene`
|
||||||
|
in ONE process (`:demo:run --args=--screenshot-all=<dir>`, wired in
|
||||||
|
`MainJvm.kt`); the native leg launches the exe once per screen
|
||||||
|
(`--screen=<Name> --screenshot=<x>.bmp`). Output lands in **`build/parity/`
|
||||||
|
(gitignored)**: `<pct>_<Name>_diff.png` (amplified difference heatmap),
|
||||||
|
`<pct>_<Name>_compare.png` (native ∣ jvm ∣ diff, side by side), and
|
||||||
|
`report.txt` ranked worst-first. Windows-only for the native leg today; needs
|
||||||
|
Pillow. See `scripts/parity/README.md`.
|
||||||
|
|
||||||
|
**What the number means — read this before trusting it.** The `%differ` is the
|
||||||
|
fraction of pixels whose per-channel difference exceeds a tolerance. It is NOT
|
||||||
|
a pass/fail score and pixel-perfection is not the goal: the two stacks use
|
||||||
|
different default fonts, so **every screen carries a steady baseline
|
||||||
|
difference** — in the heatmap, text shows as a faint *doubled ghost* from
|
||||||
|
slightly different line metrics/baselines. A healthy full sweep is a smooth
|
||||||
|
gradient (~2% for a sparse screen like Counter, up to ~32% for a text-dense
|
||||||
|
one like Tabs). Known SDL parity gaps also inflate specific screens
|
||||||
|
predictably (Brushes ~23% — gradients render solid on SDL; Shadows / Canvas /
|
||||||
|
GraphicsLayer — effect differences). **The signal is the RANKING and the
|
||||||
|
delta from a screen's own history**, not the absolute value: a text-light
|
||||||
|
screen suddenly reading 60%, or a screen jumping far above its neighbours, is
|
||||||
|
the bug. In a `_diff.png`: ghosted/doubled text + dark shapes = normal font
|
||||||
|
drift; a **solid bright block, or a shape present on only one side** = a real
|
||||||
|
regression — open the `_compare.png` to see which stack is wrong.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
Kotlin standard style — plain `camelCase` for parameters, local variables,
|
Kotlin standard style — plain `camelCase` for parameters, local variables,
|
||||||
@@ -512,7 +606,10 @@ that should surface in tooling.
|
|||||||
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
|
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
|
||||||
while per-target compilation is fine). Declare EVERY artifact the common
|
while per-target compilation is fine). Declare EVERY artifact the common
|
||||||
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
|
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
|
||||||
own bridge rule. Note only the macOS publish job compiles common metadata —
|
own bridge rule. Note only the WINDOWS publish job compiles common metadata
|
||||||
|
(it owns the root KotlinMultiplatform publications — the only host that
|
||||||
|
declares every target, so only its .module files carry the full variant
|
||||||
|
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
|
||||||
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
|
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
|
||||||
- **`Path()` in commonMain returns different actuals per renderer** —
|
- **`Path()` in commonMain returns different actuals per renderer** —
|
||||||
the Skia renderer produces a `SkiaBackedPath` (wraps
|
the Skia renderer produces a `SkiaBackedPath` (wraps
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
# Next session — renderer caching / retained layers
|
||||||
|
|
||||||
|
## RESOLVED: the 70-vs-144 "gap" was two monitors, not a bug
|
||||||
|
|
||||||
|
The user has a **75 Hz main screen + a 144 Hz second screen**. Each app's FPS is
|
||||||
|
just the refresh of whichever monitor its window opened on (SDL locks vsync to
|
||||||
|
the window's current display). demo happened to open on the 75 Hz screen,
|
||||||
|
apidemo on the 144 Hz one. Move the demo to the 144 Hz screen and it hits ~144.
|
||||||
|
|
||||||
|
Instrumentation confirmed there is NO performance problem: both apps have only
|
||||||
|
~3.7 ms `draw` per frame (demo geo=40 masks=0; apidemo geo=146 masks=74 —
|
||||||
|
apidemo does MORE and is fine), which fits both the 13.3 ms (75 Hz) and 6.7 ms
|
||||||
|
(144 Hz) budgets with large headroom. Everything is present/vsync-bound.
|
||||||
|
|
||||||
|
**Implication:** the retained-layers / dirty-region / cacheKey work is a
|
||||||
|
SPECULATIVE optimization with NO demonstrated need right now — no app misses its
|
||||||
|
frame budget. Don't spend the big content-redirect-primitive effort chasing a
|
||||||
|
phantom. Revisit only if a real, animated, heavy screen is measured (with
|
||||||
|
CDN_PROFILE, ON the target monitor) to exceed its budget.
|
||||||
|
|
||||||
|
Still worth doing regardless of perf (correctness / cleanup, small):
|
||||||
|
- Delete the dead `cacheKey` scaffolding (`GraphicsLayerModifier` /
|
||||||
|
`GraphicsLayerNode`, unread) OR wire it — and fix the demo's misleading
|
||||||
|
"(cached)" label (it uses a plain `graphicsLayer()`).
|
||||||
|
|
||||||
|
--- Original investigation notes below (kept for context) ---
|
||||||
|
|
||||||
|
|
||||||
|
Starting point after v0.1.19. Context for the perf work the profiling pointed at.
|
||||||
|
|
||||||
|
## The problem (measured)
|
||||||
|
|
||||||
|
The demo caps at ~70 fps on every screen while apidemo hits 144. Cause is NOT
|
||||||
|
vsync (both use `SDL_SetRenderVSync(renderer, 1)`) — the demo is **draw-bound**:
|
||||||
|
`CDN_PROFILE=1` showed draw ~34 ms cold / ~14 ms steady, present only ~1.3 ms.
|
||||||
|
The renderer is **immediate-mode**: every rendered frame walks the layout tree
|
||||||
|
and re-tessellates everything (text → glyph quads, shapes → triangles). The
|
||||||
|
always-present sidebar (30+ text rows + icons) is re-tessellated each frame.
|
||||||
|
|
||||||
|
Two open threads:
|
||||||
|
|
||||||
|
### FINDING (updated) — the 70-vs-144 is NOT reproducible on a 75 Hz dev box
|
||||||
|
|
||||||
|
Instrumented the draw phase (`DrawStats`: geo/verts/masks/text/img) + added
|
||||||
|
`CDN_FORCERENDER=1`. Measured both apps forced-continuous:
|
||||||
|
- demo: draw ~3.7ms, geo=40, verts=6582, masks=0, text=51
|
||||||
|
- apidemo: draw ~2.9ms, geo=146, verts=18576, masks=74, text=174
|
||||||
|
BOTH pinned at exactly 150 frames/2s = 75 fps with present ~9ms — i.e. the DEV
|
||||||
|
DISPLAY is 75 Hz, so everything is present/vsync-bound here and the demo's draw
|
||||||
|
is actually LIGHTER than apidemo's. The "demo is draw-bound" theory is WRONG on
|
||||||
|
this hardware; the 70-vs-144 split is specific to the user's 144 Hz monitor and
|
||||||
|
can't be reproduced/diagnosed from a 75 Hz box.
|
||||||
|
|
||||||
|
NEXT STEP FOR THE USER (144 Hz machine): run both apps with
|
||||||
|
`CDN_FORCERENDER=1 CDN_PROFILE=1` and share the `cdn_profile.log` lines. If demo
|
||||||
|
shows present ~13ms (75 fps) while apidemo shows present ~7ms (144 fps) at
|
||||||
|
similar draw times, it's a present/vsync-path difference (driver / swap
|
||||||
|
interval / DWM), NOT draw cost — a different investigation than caching.
|
||||||
|
|
||||||
|
The demo idles correctly when static (verified: 2 frames headless, even while
|
||||||
|
hovering the sidebar), so there's NO spurious-continuous-render bug — thread A
|
||||||
|
below is resolved as a non-issue.
|
||||||
|
|
||||||
|
### A. Why does the demo render CONTINUOUSLY? (investigate FIRST — likely cheaper)
|
||||||
|
|
||||||
|
Headless the demo idled (2 frames); interactively you see a constant 70-71 fps,
|
||||||
|
so something invalidates every frame. Prime suspect: a **hover self-loop** — the
|
||||||
|
main loop dispatches a synthetic hover every rendered frame
|
||||||
|
(`ComposeWindow.renderFrame`: `if (hasMousePos) host.onPointerRaw(...)`), and if
|
||||||
|
that perpetually re-invalidates a hover-reactive sidebar row, the app never
|
||||||
|
idles while the cursor is over it. If confirmed, the fix is to stop the spurious
|
||||||
|
invalidation so static screens idle (→ sidebar stops re-tessellating because
|
||||||
|
nothing renders) — small and targeted, no caching needed.
|
||||||
|
- Quick test: does the demo's FPS-title stop updating when the mouse leaves the
|
||||||
|
window? If yes → hover loop confirmed.
|
||||||
|
- Look at: `renderFrame` synthetic hover; how hover state feeds `needsFrame` /
|
||||||
|
`hasPendingWork` / `shouldRender()`.
|
||||||
|
|
||||||
|
### B. cacheKey / retained-layer texture caching (bigger — the "renderer rewrite")
|
||||||
|
|
||||||
|
`Modifier.graphicsLayer(cacheKey=…)` exists as API (`GraphicsLayerModifier` /
|
||||||
|
`GraphicsLayerNode` in `element/ModifierElements.kt`) but is **dead
|
||||||
|
scaffolding**: `cacheKey` is stored and never read by the renderer; the node is
|
||||||
|
a bare `Modifier.Node` with no draw behaviour (comment: "stays dormant until the
|
||||||
|
renderer rewrite drives it"). The demo's GraphicsLayer "(cached)" section uses a
|
||||||
|
plain `graphicsLayer()` — the label is aspirational; nothing is cached.
|
||||||
|
|
||||||
|
Blocker for real texture caching: it needs to render a subtree's `drawContent()`
|
||||||
|
into an **offscreen canvas**, and the renderer has no way to redirect
|
||||||
|
`drawContent()` off the frame canvas. `GraphicsLayer.native.draw()` replays its
|
||||||
|
recorded block against a canvas via `drawScope.draw(…, canvas, …)`, but
|
||||||
|
`drawContent()` is bound to the outer `ContentDrawScope`'s canvas, which nothing
|
||||||
|
swaps. The vector/icon offscreen path works only because it draws an explicit
|
||||||
|
object, not opaque `drawContent()`.
|
||||||
|
|
||||||
|
So the real unit of work is a **content-redirect primitive**: make the
|
||||||
|
`ContentDrawScope` canvas swappable (or add a coordinator-level "draw this
|
||||||
|
subtree into canvas X"). That primitive is the foundation for BOTH cacheKey
|
||||||
|
(retained layers) AND dirty-region rendering. Infrastructure that already
|
||||||
|
exists to build on: `OffscreenRenderer` (`createImageBitmap` + `createCanvas`),
|
||||||
|
`SdlImageBitmap` render-target textures + `drawImage` blit-back, and the
|
||||||
|
`NativeReleaseQueue` for freeing cached textures.
|
||||||
|
|
||||||
|
Cleanup to do regardless: either implement `cacheKey` or delete the dead
|
||||||
|
`GraphicsLayerModifier`/`GraphicsLayerNode` scaffolding and fix the demo's
|
||||||
|
misleading "(cached)" label.
|
||||||
|
|
||||||
|
## Tooling ready for this work
|
||||||
|
|
||||||
|
- `CDN_PROFILE=1 <app>` → per-phase timings (layout/draw/present) to a file.
|
||||||
|
- `scripts/parity/parity.py` → native-vs-JVM screenshot diff (regression net).
|
||||||
|
- `scripts/probe/probe.py` → drive a native window (click/hover/hold) + capture.
|
||||||
|
- See ROADMAP.md item 2 (dirty regions + retained layers promoted, with the
|
||||||
|
demo evidence) and CLAUDE.md "Tooling".
|
||||||
@@ -261,6 +261,24 @@ RESUMED, unfocused → STARTED, minimized → CREATED), `ViewModelStoreOwner`
|
|||||||
ViewModels scope per nav entry, per window, or anywhere in between — same
|
ViewModels scope per nav entry, per window, or anywhere in between — same
|
||||||
semantics as Android.
|
semantics as Android.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
Helper scripts live under `scripts/`; each has its own README with detail.
|
||||||
|
|
||||||
|
| Tool | Use it to | More |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `scripts/build-sdl/build-all.py` | build the static SDL3 / TTF / image / FreeType libs | see **Building** above |
|
||||||
|
| `scripts/compose-fork/sync.sh` | re-sync the vendored upstream Compose sources | [README](scripts/compose-fork/README.md) |
|
||||||
|
| `scripts/parity/parity.py` | diff every demo screen native-vs-JVM to catch render regressions | [README](scripts/parity/README.md) |
|
||||||
|
| `scripts/probe/probe.py` | drive a native window (click/hover/hold) + screenshot it | [README](scripts/probe/README.md) |
|
||||||
|
| `CDN_PROFILE=1 <app>` | print per-phase frame timings to find slow frames | — |
|
||||||
|
| `python scripts/compose-coverage.py` | measure API coverage vs upstream (after `./gradlew apiDump`) | — |
|
||||||
|
|
||||||
|
Building on the port from another project? Use the bridge plugin —
|
||||||
|
[gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||||
|
Contributor-facing detail (including how to read the parity `%differ`) is in
|
||||||
|
[CLAUDE.md](CLAUDE.md#tooling--whats-available-and-when-to-reach-for-it).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE.md).
|
[MIT](LICENSE.md).
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# Renderer roadmap — fixes, performance, memory
|
||||||
|
|
||||||
|
Grounded in the bubble-wrap findings (2026-07): lazy clips took the stress
|
||||||
|
sheet from 23 → 39 FPS, but 75 Hz needs structural work; issue #2 showed that
|
||||||
|
Cleaner-managed native memory starves when the Kotlin heap is quiet.
|
||||||
|
|
||||||
|
## 1. Deterministic native-resource lifecycle
|
||||||
|
|
||||||
|
Ownership closes resources the moment they're finished; GC stays as the
|
||||||
|
safeguard, not the mechanism.
|
||||||
|
|
||||||
|
- [x] **`NativeReleaseQueue`** — SDL calls are main-thread-only, so nothing may
|
||||||
|
destroy directly from a Cleaner or worker: release actions ENQUEUE, the main
|
||||||
|
loop DRAINS between frames. Cleaners become safe from any thread because
|
||||||
|
they only enqueue. Verified end-to-end (direct enqueue + a real
|
||||||
|
Cleaner→enqueue→drain cycle).
|
||||||
|
- [x] **`SdlImageBitmap` textures** — previously never destroyed (manual SDL
|
||||||
|
memory, invisible to the GC nudge). A holder + Cleaner now enqueues
|
||||||
|
`SDL_DestroyTexture` / `SDL_DestroySurface` for the texture, render target
|
||||||
|
and never-realized decoded surface; explicit `close()` frees promptly.
|
||||||
|
- [ ] **Wire `close()` into cache eviction** — the Cleaner is the current
|
||||||
|
release path for decoded/vector bitmaps (correct but GC-timed). Call
|
||||||
|
`SdlImageBitmap.close()` on image-cache eviction / `removeMemoryResource`
|
||||||
|
for prompt release (needs a cross-module hook — the caches hold the
|
||||||
|
`ImageBitmap` interface, not the concrete type).
|
||||||
|
- [ ] **Cache-eviction closes (rest)** — every image/text cache closes what it
|
||||||
|
evicts (Sdl3ImageCache already does; sweep the rest, both renderers).
|
||||||
|
- [ ] **Renderer `destroy()` chain** — window close synchronously frees every
|
||||||
|
pool (clip targets already do; text caches, image caches, shadow cache,
|
||||||
|
typefaces to follow).
|
||||||
|
- [ ] **`SkiaImageBitmap.close()`** (surface + snapshot) + close-on-eviction in
|
||||||
|
the Skia caches. *Skia source sets build only on macOS/Linux — needs a mac
|
||||||
|
or CI leg to verify.*
|
||||||
|
- [ ] **Demote the GC nudge** — once ownership does the work, stretch the 10s
|
||||||
|
interval / gate it on churn; it remains the backstop.
|
||||||
|
|
||||||
|
Verification: `demo --leaktest`-style probe (cycle screens N times, print
|
||||||
|
RSS; expect a plateau without GC sawtooth).
|
||||||
|
|
||||||
|
## 2. SDL renderer performance (the 39 → 75 Hz path)
|
||||||
|
|
||||||
|
- [x] **Frame profiler** — `CDN_PROFILE=1` env flag prints per-phase timings
|
||||||
|
(events / app pump / window pump / render / present) every ~2 s. Measure
|
||||||
|
first, optimize second.
|
||||||
|
- [ ] **Dirty-region rendering** — NEXT, promoted by profiling: the app
|
||||||
|
re-tessellates the whole window on any invalidation. Accumulate damage from
|
||||||
|
invalidated layers and scissor the redraw. Biggest lever for heavy scenes.
|
||||||
|
Evidence (CDN_PROFILE): the full demo is DRAW-bound — draw ~34ms cold /
|
||||||
|
~14ms steady, present only ~1.3ms — because it re-tessellates the whole
|
||||||
|
always-present sidebar (30+ text rows + icons) every rendered frame. That
|
||||||
|
~14ms caps it near 70 fps; apidemo's lighter chrome fits the 6.9ms budget
|
||||||
|
and reaches the 144 Hz vsync ceiling. The "70 ≈ 144/2" look is coincidence,
|
||||||
|
NOT a vsync-half artifact — both use vsync(1). Retained layers / dirty
|
||||||
|
regions would stop re-tessellating the static sidebar.
|
||||||
|
- [ ] **Retained layer textures** — cache a layer's rendered output keyed by
|
||||||
|
its draw content (RenderNode-style); static subtrees stop re-tessellating.
|
||||||
|
- [ ] **Right-size clip scratch targets** (LOW — profiling showed masks aren't
|
||||||
|
a hot path after lazy clips): size scratch textures to the clip bbox.
|
||||||
|
Memory, not frame time.
|
||||||
|
- [ ] **Glyph atlas** — text draws currently break geometry batches (z-order
|
||||||
|
flush per run); an atlas texture lets glyphs ride the vertex batches.
|
||||||
|
|
||||||
|
## 3. Skia renderer
|
||||||
|
|
||||||
|
- [ ] Bounded `SkiaImageCache` (LRU + eviction close) — unbounded HashMap today.
|
||||||
|
- [ ] Eager-close audit of the text pipeline (mirror of the SDL sweep).
|
||||||
|
- [ ] `saveLayer` huge-bounds clamp (GPU offscreen memory spikes — see
|
||||||
|
CLAUDE.md pitfall).
|
||||||
|
|
||||||
|
## 4. Correctness / parity gaps (future bug reports waiting)
|
||||||
|
|
||||||
|
Ordered by likelihood of a user hitting them. NOTE: this list predates several
|
||||||
|
fixes — verify a claim against the parity heatmaps before acting (gradients and
|
||||||
|
rotation both turned out already-working). In a parity `_diff.png`: a solid
|
||||||
|
bright block = real gap; ghosted text = font drift.
|
||||||
|
|
||||||
|
- [x] **Gradient brushes on SDL** — samplers were already implemented; the real
|
||||||
|
bug was UNDER-SAMPLING: fills sampled only corners, so radial/sweep on a
|
||||||
|
rect/round-rect rendered FLAT (a square's corners are equidistant from centre
|
||||||
|
→ identical t). Gradient fills now grid-mesh the interior (emitRectMesh);
|
||||||
|
radial/sweep match upstream. (Solid fills unchanged — one quad.)
|
||||||
|
- [x] **Layer rotation** — verified WORKING (demo GraphicsLayer "Rotation
|
||||||
|
(live)" + "Combined" rotate correctly); stale claim removed.
|
||||||
|
- [ ] **Real `saveLayer` alpha on SDL** — overlapping content composites at
|
||||||
|
paint level; needs an offscreen (the clip-target pool can serve it).
|
||||||
|
- [ ] **`clipPath` generic shapes** — bbox fallback clips square.
|
||||||
|
|
||||||
|
## 5. Permanent tooling
|
||||||
|
|
||||||
|
- [x] **Parity harness** — scripts/parity/: renders every :demo screen native +
|
||||||
|
JVM from the same commonMain composables, pixel-diffs, ranks by %differ,
|
||||||
|
emits per-screen diff heatmaps + side-by-side compares (pct in filename).
|
||||||
|
build/parity/ (gitignored). See scripts/parity/README.md.
|
||||||
|
- [x] Promote the press/hover automation rig into `scripts/probe/`:
|
||||||
|
window-CLIENT-relative input (click/hover/hold, fractional coords, process-
|
||||||
|
addressed) + PrintWindow capture (works occluded). See scripts/probe/README.md.
|
||||||
|
|
||||||
|
## 6. Long-term
|
||||||
|
|
||||||
|
- [ ] **SDL_GPU render backend** — real stencil clipping (no masks at all),
|
||||||
|
pipelined batching, shader gradients. Weeks of work across three
|
||||||
|
platforms; items 2–4 stay useful beneath it.
|
||||||
|
|
||||||
|
Suggested order: profiler → lifecycle queue + leak closes → clip-target
|
||||||
|
right-sizing → gradients → dirty regions → the rest by demand.
|
||||||
@@ -32,8 +32,11 @@ import com.compose.sdl.window.createPopupHostState
|
|||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.cinterop.toKString
|
||||||
import kotlinx.cinterop.reinterpret
|
import kotlinx.cinterop.reinterpret
|
||||||
import sdl3.SDL_Delay
|
import sdl3.SDL_Delay
|
||||||
|
import sdl3.SDL_GetPerformanceCounter
|
||||||
|
import sdl3.SDL_GetPerformanceFrequency
|
||||||
import sdl3.SDL_GetTicks
|
import sdl3.SDL_GetTicks
|
||||||
import sdl3.SDL_Quit
|
import sdl3.SDL_Quit
|
||||||
import sdl3.SDL_SetWindowTitle
|
import sdl3.SDL_SetWindowTitle
|
||||||
@@ -149,6 +152,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
var vGcLastTicks = SDL_GetTicks()
|
var vGcLastTicks = SDL_GetTicks()
|
||||||
var vRenderedSinceGc = false
|
var vRenderedSinceGc = false
|
||||||
while (!runtime.exitRequested) {
|
while (!runtime.exitRequested) {
|
||||||
|
FrameProfiler.mark()
|
||||||
Snapshot.sendApplyNotifications()
|
Snapshot.sendApplyNotifications()
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
@@ -176,6 +180,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mainDispatcher.drainPending()
|
mainDispatcher.drainPending()
|
||||||
|
FrameProfiler.phase("events")
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// App composition pump — Window()s may appear / disappear here.
|
// App composition pump — Window()s may appear / disappear here.
|
||||||
@@ -195,6 +200,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
// Exit when the last window is gone (after at least one existed).
|
// Exit when the last window is gone (after at least one existed).
|
||||||
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
|
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
|
||||||
if (runtime.exitRequested) break
|
if (runtime.exitRequested) break
|
||||||
|
FrameProfiler.phase("app")
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Per-window pump + render.
|
// Per-window pump + render.
|
||||||
@@ -208,13 +214,24 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
Snapshot.sendApplyNotifications()
|
Snapshot.sendApplyNotifications()
|
||||||
vW.frameClock.sendFrame()
|
vW.frameClock.sendFrame()
|
||||||
yield()
|
yield()
|
||||||
|
FrameProfiler.phase("pump")
|
||||||
if (vW.shouldRender()) {
|
if (vW.shouldRender()) {
|
||||||
vW.renderFrame()
|
vW.renderFrame()
|
||||||
vAnyRendered = true
|
vAnyRendered = true
|
||||||
if (!vW.backend.vsyncEnabled) vAllVsync = false
|
if (!vW.backend.vsyncEnabled) vAllVsync = false
|
||||||
}
|
}
|
||||||
|
FrameProfiler.phase("render")
|
||||||
}
|
}
|
||||||
runtime.reapDestroyed()
|
runtime.reapDestroyed()
|
||||||
|
FrameProfiler.frameDone(vAnyRendered)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Drain deferred native-resource disposals on the MAIN thread —
|
||||||
|
// textures/surfaces whose owner closed them or whose Cleaner fired
|
||||||
|
// on a GC worker enqueue here (SDL calls aren't thread-safe). This
|
||||||
|
// is the ownership path that makes the GC nudge below a mere
|
||||||
|
// backstop (ROADMAP.md item 1).
|
||||||
|
com.compose.sdl.graphics.NativeReleaseQueue.drain()
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Pace / idle-skip.
|
// Pace / idle-skip.
|
||||||
@@ -250,6 +267,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
snapshotHandle.dispose()
|
snapshotHandle.dispose()
|
||||||
for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW)
|
for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW)
|
||||||
runtime.reapDestroyed()
|
runtime.reapDestroyed()
|
||||||
|
com.compose.sdl.graphics.NativeReleaseQueue.drain()
|
||||||
appComposition.dispose()
|
appComposition.dispose()
|
||||||
appRecomposer.cancel()
|
appRecomposer.cancel()
|
||||||
appRecomposeJob.cancelAndJoin()
|
appRecomposeJob.cancelAndJoin()
|
||||||
@@ -262,8 +280,78 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
|||||||
SDL_Quit()
|
SDL_Quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
|
||||||
|
A named-phase SINGLETON so both the main loop (events / app / pump / render)
|
||||||
|
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
|
||||||
|
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
|
||||||
|
the elapsed since the last mark/phase to that name. Measure first, optimize
|
||||||
|
second — see ROADMAP.md. */
|
||||||
|
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||||
|
internal object FrameProfiler {
|
||||||
|
// null until first checked; then true/false for the run's lifetime.
|
||||||
|
private var fEnabled: Boolean? = null
|
||||||
|
// Output file — resolved once from CDN_PROFILE. Writing to a file (not
|
||||||
|
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
|
||||||
|
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
|
||||||
|
// the cwd; CDN_PROFILE=<path> → that path.
|
||||||
|
private var fPath: String = "cdn_profile.log"
|
||||||
|
val enabled: Boolean
|
||||||
|
get() = fEnabled ?: run {
|
||||||
|
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
|
||||||
|
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
|
||||||
|
(vEnv != null).also { fEnabled = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
|
||||||
|
// Insertion-ordered so the printed line follows the call order.
|
||||||
|
private val fSum = LinkedHashMap<String, Double>()
|
||||||
|
private val fMax = LinkedHashMap<String, Double>()
|
||||||
|
private var fFrames = 0
|
||||||
|
private var fLastPrintMs = SDL_GetTicks()
|
||||||
|
private var fMark = 0uL
|
||||||
|
|
||||||
|
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
|
||||||
|
|
||||||
|
fun phase(inName: String) {
|
||||||
|
if (!enabled) return
|
||||||
|
val vNow = SDL_GetPerformanceCounter()
|
||||||
|
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
|
||||||
|
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
|
||||||
|
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
|
||||||
|
fMark = vNow
|
||||||
|
}
|
||||||
|
|
||||||
|
fun frameDone(inRendered: Boolean) {
|
||||||
|
if (!enabled) return
|
||||||
|
if (inRendered) fFrames++
|
||||||
|
val vNowMs = SDL_GetTicks()
|
||||||
|
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
|
||||||
|
val vParts = fSum.keys.map { vName ->
|
||||||
|
val vAvg = (fSum[vName] ?: 0.0) / fFrames
|
||||||
|
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
|
||||||
|
}
|
||||||
|
// Per-frame draw-work averages (see DrawStats): what's inside `draw`.
|
||||||
|
val vStats = com.compose.sdl.graphics.DrawStats
|
||||||
|
val vDraw = "geo=${vStats.geometrySubmits / fFrames} verts=${vStats.vertices / fFrames} " +
|
||||||
|
"masks=${vStats.maskRealizations / fFrames} text=${vStats.textDraws / fFrames} img=${vStats.imageBlits / fFrames}"
|
||||||
|
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " | per-frame " + vDraw + "\n"
|
||||||
|
val vFile = platform.posix.fopen(fPath, "a")
|
||||||
|
if (vFile != null) {
|
||||||
|
platform.posix.fputs(vLine, vFile)
|
||||||
|
platform.posix.fclose(vFile)
|
||||||
|
}
|
||||||
|
vStats.reset()
|
||||||
|
fSum.clear(); fMax.clear()
|
||||||
|
fFrames = 0
|
||||||
|
fLastPrintMs = vNowMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
|
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
|
||||||
their native memory (see the main loop's native-memory nudge). */
|
their native memory (see the main loop's native-memory nudge). */
|
||||||
|
private val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
|
||||||
|
|
||||||
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
|
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
|
||||||
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
|
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
|
||||||
|
|
||||||
@@ -664,7 +752,10 @@ internal class WindowInstance(
|
|||||||
// Frame pump
|
// Frame pump
|
||||||
|
|
||||||
fun shouldRender(): Boolean =
|
fun shouldRender(): Boolean =
|
||||||
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null
|
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
|
||||||
|
|
||||||
|
// TEMP measurement: CDN_FORCERENDER=1 forces every frame to render so
|
||||||
|
// sustained steady-state timings can be measured on otherwise-idle screens.
|
||||||
|
|
||||||
fun renderFrame() {
|
fun renderFrame() {
|
||||||
val vRender = renderBackend ?: return
|
val vRender = renderBackend ?: return
|
||||||
@@ -675,6 +766,7 @@ internal class WindowInstance(
|
|||||||
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
|
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
|
||||||
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
|
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
|
||||||
host.measureAndLayout()
|
host.measureAndLayout()
|
||||||
|
FrameProfiler.phase(" layout")
|
||||||
|
|
||||||
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
|
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
|
||||||
if (hasMousePos) {
|
if (hasMousePos) {
|
||||||
@@ -687,7 +779,9 @@ internal class WindowInstance(
|
|||||||
// Probe consumers end the app when their scenario completes.
|
// Probe consumers end the app when their scenario completes.
|
||||||
facade.close()
|
facade.close()
|
||||||
}
|
}
|
||||||
|
FrameProfiler.phase(" draw")
|
||||||
vRender.endFrame()
|
vRender.endFrame()
|
||||||
|
FrameProfiler.phase(" present")
|
||||||
frameIndex++
|
frameIndex++
|
||||||
|
|
||||||
// FPS — refreshed ~once a second, per window.
|
// FPS — refreshed ~once a second, per window.
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
package com.compose.sdl.element
|
|
||||||
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
|
||||||
import androidx.compose.ui.node.ModifierNodeElement
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Project modifier elements
|
|
||||||
// ==================
|
|
||||||
// Small set of project-only modifier elements — each pairs a
|
|
||||||
// `ModifierNodeElement<XxxNode>` with a `XxxNode : Modifier.Node`. Upstream
|
|
||||||
// LayoutNode reads them through the chain (ModifierNodeElement IS-A
|
|
||||||
// Modifier.Element). Everything gesture-related has been removed — pointer
|
|
||||||
// input, text input, right/middle click, drag: all go through the standard
|
|
||||||
// `Modifier.pointerInput` + `detectTapGestures` / `detectDragGestures` /
|
|
||||||
// `awaitPointerEventScope` APIs upstream now supplies. What's left is the
|
|
||||||
// two elements the renderer still owns directly:
|
|
||||||
// * ClipModifier — GraphicsLayer.kt lowers `clip = true` to it.
|
|
||||||
// * GraphicsLayerModifier — the transform / alpha / cache pipeline.
|
|
||||||
|
|
||||||
class ClipModifier(val shape: Shape) : ModifierNodeElement<ClipNode>() {
|
|
||||||
override fun create() = ClipNode(shape)
|
|
||||||
override fun update(node: ClipNode) { node.shape = shape }
|
|
||||||
override fun hashCode(): Int = shape.hashCode()
|
|
||||||
override fun equals(other: Any?): Boolean = other is ClipModifier && other.shape == shape
|
|
||||||
}
|
|
||||||
class ClipNode(var shape: Shape) : Modifier.Node()
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: GraphicsLayerModifier
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A "graphics layer" element: alpha + 2D transform (scale / rotation /
|
|
||||||
* translation), with an optional cacheKey that opts the subtree into
|
|
||||||
* render-to-texture caching across frames. See `Modifier.graphicsLayer`
|
|
||||||
* (in `androidx.compose.ui.graphics`) for the caching semantics.
|
|
||||||
*
|
|
||||||
* The renderer reads this element directly via the `LayoutNode.graphicsLayer`
|
|
||||||
* `foldIn` over the chain; the paired [GraphicsLayerNode] lifecycle stays
|
|
||||||
* dormant until the renderer rewrite drives it.
|
|
||||||
*/
|
|
||||||
class GraphicsLayerModifier(
|
|
||||||
val alpha: Float = 1f,
|
|
||||||
val scaleX: Float = 1f,
|
|
||||||
val scaleY: Float = 1f,
|
|
||||||
val rotationZ: Float = 0f,
|
|
||||||
val translationX: Float = 0f,
|
|
||||||
val translationY: Float = 0f,
|
|
||||||
val transformOrigin: TransformOrigin = TransformOrigin.Center,
|
|
||||||
val cacheKey: Any? = null,
|
|
||||||
) : ModifierNodeElement<GraphicsLayerNode>() {
|
|
||||||
|
|
||||||
val needsLayer: Boolean
|
|
||||||
get() = alpha < 1f || cacheKey != null
|
|
||||||
|
|
||||||
val needsTransform: Boolean
|
|
||||||
get() = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
|
|
||||||
translationX != 0f || translationY != 0f
|
|
||||||
|
|
||||||
val isIdentity: Boolean
|
|
||||||
get() = !needsLayer && !needsTransform
|
|
||||||
|
|
||||||
override fun create() =
|
|
||||||
GraphicsLayerNode(alpha, scaleX, scaleY, rotationZ, translationX, translationY, transformOrigin, cacheKey)
|
|
||||||
override fun update(node: GraphicsLayerNode) {
|
|
||||||
node.alpha = alpha; node.scaleX = scaleX; node.scaleY = scaleY
|
|
||||||
node.rotationZ = rotationZ; node.translationX = translationX; node.translationY = translationY
|
|
||||||
node.transformOrigin = transformOrigin; node.cacheKey = cacheKey
|
|
||||||
}
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var v = alpha.hashCode()
|
|
||||||
v = 31 * v + scaleX.hashCode(); v = 31 * v + scaleY.hashCode()
|
|
||||||
v = 31 * v + rotationZ.hashCode()
|
|
||||||
v = 31 * v + translationX.hashCode(); v = 31 * v + translationY.hashCode()
|
|
||||||
v = 31 * v + transformOrigin.hashCode(); v = 31 * v + (cacheKey?.hashCode() ?: 0)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
override fun equals(other: Any?): Boolean =
|
|
||||||
other is GraphicsLayerModifier &&
|
|
||||||
other.alpha == alpha && other.scaleX == scaleX && other.scaleY == scaleY &&
|
|
||||||
other.rotationZ == rotationZ &&
|
|
||||||
other.translationX == translationX && other.translationY == translationY &&
|
|
||||||
other.transformOrigin == transformOrigin && other.cacheKey == cacheKey
|
|
||||||
}
|
|
||||||
|
|
||||||
class GraphicsLayerNode(
|
|
||||||
var alpha: Float,
|
|
||||||
var scaleX: Float,
|
|
||||||
var scaleY: Float,
|
|
||||||
var rotationZ: Float,
|
|
||||||
var translationX: Float,
|
|
||||||
var translationY: Float,
|
|
||||||
var transformOrigin: TransformOrigin,
|
|
||||||
var cacheKey: Any?,
|
|
||||||
) : Modifier.Node()
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.compose.sdl
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Renderer entry points (expect)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/* The renderer seam :window builds against. Each renderer source set
|
||||||
|
(skikoRendererMain / sdlRendererMain) supplies the actuals; exactly one of
|
||||||
|
the two is attached to any given target, so resolution stays unambiguous.
|
||||||
|
|
||||||
|
These were originally declared identically in both renderer source sets
|
||||||
|
with NO expect — fine per-target, but shared nativeMain METADATA (=: what
|
||||||
|
:window's KotlinMultiplatform publication compiles against) could not see
|
||||||
|
them on a host where the attached targets span BOTH renderers (Windows:
|
||||||
|
skikoRenderer for macos/linux + sdlRenderer for mingw). The expect makes
|
||||||
|
nativeMain metadata self-contained, which lets the WINDOWS publish job —
|
||||||
|
the only host that declares every target — produce the root modules. */
|
||||||
|
|
||||||
|
/* Create the render backend for the selected [GpuMode]; null when the
|
||||||
|
backend can't initialise (caller falls back / reports). */
|
||||||
|
expect fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend?
|
||||||
|
|
||||||
|
/* The renderer module's per-OS default GpuMode (used for GpuMode.Auto). */
|
||||||
|
expect fun rendererPreferredGpuMode(): GpuMode
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.compose.sdl.graphics
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: DrawStats — per-frame renderer work counters (diagnostic)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap per-frame counters the SDL draw path bumps and the frame profiler
|
||||||
|
* reads, to answer "what inside `draw` costs the time?" — geometry submissions
|
||||||
|
* (SDL_RenderGeometry calls), total vertices tessellated, rounded-clip mask
|
||||||
|
* realizations (offscreen render-target passes — the expensive ones), text
|
||||||
|
* blits and image blits. Reset each frame by the profiler after reading.
|
||||||
|
*
|
||||||
|
* Single-threaded (all draw is on the main thread), so plain vars are fine.
|
||||||
|
* Zero overhead when the profiler isn't reading — just integer increments.
|
||||||
|
*/
|
||||||
|
object DrawStats {
|
||||||
|
var geometrySubmits: Int = 0
|
||||||
|
var vertices: Int = 0
|
||||||
|
var maskRealizations: Int = 0
|
||||||
|
var textDraws: Int = 0
|
||||||
|
var imageBlits: Int = 0
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
geometrySubmits = 0
|
||||||
|
vertices = 0
|
||||||
|
maskRealizations = 0
|
||||||
|
textDraws = 0
|
||||||
|
imageBlits = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun summary(): String =
|
||||||
|
"geo=$geometrySubmits verts=$vertices masks=$maskRealizations text=$textDraws img=$imageBlits"
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.compose.sdl.graphics
|
||||||
|
|
||||||
|
import kotlinx.atomicfu.locks.SynchronizedObject
|
||||||
|
import kotlinx.atomicfu.locks.synchronized
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: NativeReleaseQueue — main-thread-deferred native resource disposal
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A queue of native-resource release actions drained on the MAIN THREAD once
|
||||||
|
* per frame by the window loop (see ComposeWindow). It exists because the two
|
||||||
|
* ways a renderer resource dies are both off the main thread, while the calls
|
||||||
|
* that free them (SDL_DestroyTexture, and any renderer API) are main-thread-only:
|
||||||
|
*
|
||||||
|
* - a Kotlin/Native `Cleaner` runs on a GC worker thread, and
|
||||||
|
* - explicit `close()` can be reached from composition/effect code on other
|
||||||
|
* dispatchers.
|
||||||
|
*
|
||||||
|
* Both simply ENQUEUE here; the main loop runs the actions between frames. This
|
||||||
|
* turns the GC (issue #2: Cleaner-managed native memory starving because the
|
||||||
|
* quiet Kotlin heap rarely triggers a collection) from the disposal MECHANISM
|
||||||
|
* into a mere safeguard — ownership closes resources promptly, the Cleaner is
|
||||||
|
* the backstop for whatever leaks a `close()`.
|
||||||
|
*
|
||||||
|
* Actions must be idempotent-safe at the source (guard against a resource being
|
||||||
|
* both explicitly closed and later GC-cleaned — see SdlImageBitmap's holder).
|
||||||
|
*/
|
||||||
|
object NativeReleaseQueue {
|
||||||
|
|
||||||
|
private val fLock = SynchronizedObject()
|
||||||
|
private var fPending = ArrayList<() -> Unit>()
|
||||||
|
|
||||||
|
/** Enqueue a release action. Safe to call from any thread. */
|
||||||
|
fun enqueue(action: () -> Unit) {
|
||||||
|
synchronized(fLock) { fPending.add(action) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run and clear every queued action. MAIN THREAD ONLY — the actions call
|
||||||
|
* renderer APIs that aren't thread-safe. Returns the number drained.
|
||||||
|
*/
|
||||||
|
fun drain(): Int {
|
||||||
|
val vBatch = synchronized(fLock) {
|
||||||
|
if (fPending.isEmpty()) return 0
|
||||||
|
val vTaken = fPending
|
||||||
|
fPending = ArrayList()
|
||||||
|
vTaken
|
||||||
|
}
|
||||||
|
for (vAction in vBatch) {
|
||||||
|
runCatching { vAction() }
|
||||||
|
}
|
||||||
|
return vBatch.size
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import kotlin.experimental.ExperimentalNativeApi
|
|||||||
// MARK: SDL3 renderer per-OS default
|
// MARK: SDL3 renderer per-OS default
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
fun rendererPreferredGpuMode(): GpuMode {
|
actual fun rendererPreferredGpuMode(): GpuMode {
|
||||||
@OptIn(ExperimentalNativeApi::class)
|
@OptIn(ExperimentalNativeApi::class)
|
||||||
return when (Platform.osFamily) {
|
return when (Platform.osFamily) {
|
||||||
OsFamily.MACOSX -> GpuMode.Sdl3.Metal
|
OsFamily.MACOSX -> GpuMode.Sdl3.Metal
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.sdl.Sdl3RenderBackend
|
|||||||
renderer modules expose createRenderBackend / rendererPreferredGpuMode
|
renderer modules expose createRenderBackend / rendererPreferredGpuMode
|
||||||
with identical signatures in this package, and the build includes exactly
|
with identical signatures in this package, and the build includes exactly
|
||||||
one of them per target. Rejects Skia.* since this module has no Skiko. */
|
one of them per target. Rejects Skia.* since this module has no Skiko. */
|
||||||
fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
|
actual fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
|
||||||
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||||
if (vResolved is GpuMode.Skia) {
|
if (vResolved is GpuMode.Skia) {
|
||||||
error("$vResolved isn't available in this build — Skiko isn't linked. " +
|
error("$vResolved isn't available in this build — Skiko isn't linked. " +
|
||||||
|
|||||||
+230
-30
@@ -128,7 +128,7 @@ internal class Sdl3Canvas(
|
|||||||
// offscreen clip opened inside this save frame (see fClipLayers below).
|
// offscreen clip opened inside this save frame (see fClipLayers below).
|
||||||
private data class State(
|
private data class State(
|
||||||
val a: Float, val b: Float, val c: Float, val d: Float, val e: Float, val f: Float,
|
val a: Float, val b: Float, val c: Float, val d: Float, val e: Float, val f: Float,
|
||||||
val clip: IntArray?, val alpha: Float, val clipLayers: Int,
|
val clip: IntArray?, val alpha: Float, val clipLayers: Int, val pendingClips: Int,
|
||||||
)
|
)
|
||||||
private val fStack = ArrayDeque<State>()
|
private val fStack = ArrayDeque<State>()
|
||||||
|
|
||||||
@@ -148,9 +148,139 @@ internal class Sdl3Canvas(
|
|||||||
)
|
)
|
||||||
private val fClipLayers = ArrayDeque<OffscreenClip>()
|
private val fClipLayers = ArrayDeque<OffscreenClip>()
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Lazy rounded clips. Opening an offscreen mask per clip is the SINGLE
|
||||||
|
// most expensive thing this canvas does (two render-target switches +
|
||||||
|
// clear + corner cut + feather + composite) - and the dominant Compose
|
||||||
|
// idiom `Modifier.clip(CircleShape).background(...)` never needs one:
|
||||||
|
// the background is a full-cover rect drawable AS the rounded shape, and
|
||||||
|
// content wholly inside the rounded region needs no mask at all. So
|
||||||
|
// clipRoundRect only RECORDS the clip; each draw either proves itself
|
||||||
|
// safe (containment / cover) or realizes the offscreen on demand.
|
||||||
|
|
||||||
|
private class PendingRoundClip(
|
||||||
|
val deviceRound: RoundRect, // device-space outline (clamped radii)
|
||||||
|
val localRound: RoundRect, // as passed in (for cover -> shape-fill emission)
|
||||||
|
val region: IntArray, // fClip intersect bbox at push time
|
||||||
|
val bbox: IntArray, // device bbox of the outline
|
||||||
|
val prevClip: IntArray?, // fClip before this clip was pushed
|
||||||
|
// Affine snapshot at push - cover -> fill re-emits in LOCAL space and
|
||||||
|
// must bail if the transform moved since.
|
||||||
|
val ma: Float, val mb: Float, val mc: Float, val md: Float, val me: Float, val mf: Float,
|
||||||
|
// fStack.size when the clip was pushed: a lazily-realized mask must be
|
||||||
|
// composited by the save-frame that OWNS the clip, not by whatever
|
||||||
|
// inner frame happened to trigger realization (a press draws the state
|
||||||
|
// layer and the ripple in separate inner clipRect{} frames - the first
|
||||||
|
// realize must not let the inner restore close the mask, or the second
|
||||||
|
// draw runs unmasked: the "square on click" artifact).
|
||||||
|
val saveDepth: Int,
|
||||||
|
)
|
||||||
|
private val fPendingClips = ArrayDeque<PendingRoundClip>()
|
||||||
|
|
||||||
|
/* Point-in-round-rect (device space) - inside the rect and, within a
|
||||||
|
corner cut square, inside its ellipse. */
|
||||||
|
private fun roundRectContains(inRR: RoundRect, inX: Float, inY: Float): Boolean {
|
||||||
|
if (inX < inRR.left || inX > inRR.right || inY < inRR.top || inY > inRR.bottom) return false
|
||||||
|
fun corner(inRx: Float, inRy: Float, inDx: Float, inDy: Float): Boolean {
|
||||||
|
if (inRx <= 0f || inRy <= 0f) return true
|
||||||
|
val vNx = inDx / inRx; val vNy = inDy / inRy
|
||||||
|
return vNx * vNx + vNy * vNy <= 1f
|
||||||
|
}
|
||||||
|
val vTl = inRR.topLeftCornerRadius; val vTr = inRR.topRightCornerRadius
|
||||||
|
val vBr = inRR.bottomRightCornerRadius; val vBl = inRR.bottomLeftCornerRadius
|
||||||
|
if (inX < inRR.left + vTl.x && inY < inRR.top + vTl.y)
|
||||||
|
return corner(vTl.x, vTl.y, inX - (inRR.left + vTl.x), inY - (inRR.top + vTl.y))
|
||||||
|
if (inX > inRR.right - vTr.x && inY < inRR.top + vTr.y)
|
||||||
|
return corner(vTr.x, vTr.y, inX - (inRR.right - vTr.x), inY - (inRR.top + vTr.y))
|
||||||
|
if (inX > inRR.right - vBr.x && inY > inRR.bottom - vBr.y)
|
||||||
|
return corner(vBr.x, vBr.y, inX - (inRR.right - vBr.x), inY - (inRR.bottom - vBr.y))
|
||||||
|
if (inX < inRR.left + vBl.x && inY > inRR.bottom - vBl.y)
|
||||||
|
return corner(vBl.x, vBl.y, inX - (inRR.left + vBl.x), inY - (inRR.bottom - vBl.y))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A device-space aabb is inside the round rect iff its four corners are. */
|
||||||
|
private fun roundRectContainsAabb(inRR: RoundRect, inL: Float, inT: Float, inR: Float, inB: Float): Boolean =
|
||||||
|
roundRectContains(inRR, inL, inT) && roundRectContains(inRR, inR, inT) &&
|
||||||
|
roundRectContains(inRR, inR, inB) && roundRectContains(inRR, inL, inB)
|
||||||
|
|
||||||
|
/* Device aabb of a LOCAL rect under the current affine. */
|
||||||
|
private fun deviceAabb(inL: Float, inT: Float, inR: Float, inB: Float): FloatArray {
|
||||||
|
val vX0 = mapX(inL, inT); val vX1 = mapX(inR, inT); val vX2 = mapX(inR, inB); val vX3 = mapX(inL, inB)
|
||||||
|
val vY0 = mapY(inL, inT); val vY1 = mapY(inR, inT); val vY2 = mapY(inR, inB); val vY3 = mapY(inL, inB)
|
||||||
|
return floatArrayOf(
|
||||||
|
minOf(vX0, vX1, vX2, vX3), minOf(vY0, vY1, vY2, vY3),
|
||||||
|
maxOf(vX0, vX1, vX2, vX3), maxOf(vY0, vY1, vY2, vY3),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If the LOCAL-space aabb stays inside every pending rounded clip, drawing
|
||||||
|
it under the already-active rect clip is correct - no mask needed.
|
||||||
|
Otherwise realize the pending masks before the draw proceeds. */
|
||||||
|
private fun admitDraw(inL: Float, inT: Float, inR: Float, inB: Float) {
|
||||||
|
if (fPendingClips.isEmpty()) return
|
||||||
|
val vBox = deviceAabb(inL, inT, inR, inB)
|
||||||
|
for (vPending in fPendingClips) {
|
||||||
|
if (!roundRectContainsAabb(vPending.deviceRound, vBox[0], vBox[1], vBox[2], vBox[3])) {
|
||||||
|
realizePendingClips()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Convert pending clips into real offscreen mask layers (outermost first).
|
||||||
|
|
||||||
|
Each clip realizes with its OWN push-time region (pending.region =
|
||||||
|
enclosing-clip-at-push ∩ bbox), NOT the current fClip: a lazily-realized
|
||||||
|
clip may fire arbitrarily deep inside descendants whose own clips have
|
||||||
|
since narrowed fClip. Re-intersecting with that narrow clip shrank an
|
||||||
|
outer clip's offscreen to the innermost descendant's rect, so its
|
||||||
|
composite blitted back only that sliver and every sibling drawn into the
|
||||||
|
same offscreen was discarded (the TLS-chain regression: only the first
|
||||||
|
card + nothing below it, until a scroll shifted which card triggered
|
||||||
|
realization). The real (already-narrowed) drawing clip is preserved
|
||||||
|
across the loop and restored at the end, so subsequent draws into the
|
||||||
|
innermost target stay correctly clipped. */
|
||||||
|
private fun realizePendingClips() {
|
||||||
|
if (fPendingClips.isEmpty()) return
|
||||||
|
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
||||||
|
val vDrawClip = fClip
|
||||||
|
while (fPendingClips.isNotEmpty()) {
|
||||||
|
val vPending = fPendingClips.removeFirst()
|
||||||
|
fScope.flush()
|
||||||
|
val vRegion = vPending.region
|
||||||
|
val vTarget = fClipTargets?.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
|
||||||
|
if (vTarget == null || vRegion[2] <= vRegion[0] || vRegion[3] <= vRegion[1]) {
|
||||||
|
// Degrade to the rect clip that is already active.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val vPrevTarget = SDL_GetRenderTarget(vRenderer)
|
||||||
|
SDL_SetRenderTarget(vRenderer, vTarget.reinterpret())
|
||||||
|
fClip = vRegion
|
||||||
|
applyClip()
|
||||||
|
clearRegion(vRegion)
|
||||||
|
com.compose.sdl.graphics.DrawStats.maskRealizations++
|
||||||
|
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound))
|
||||||
|
// Attribute the realized mask to the save-frame that pushed the
|
||||||
|
// clip: inner frames entered since must NOT composite it on their
|
||||||
|
// restore - bump their thresholds past the new layer.
|
||||||
|
for (vI in vPending.saveDepth until fStack.size) {
|
||||||
|
val vState = fStack[vI]
|
||||||
|
if (vState.clipLayers < fClipLayers.size) {
|
||||||
|
fStack[vI] = vState.copy(clipLayers = fClipLayers.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Restore the real drawing clip (already narrowed by inner clipRects /
|
||||||
|
// scroll bounds) for draws into the now-active innermost target.
|
||||||
|
fClip = vDrawClip
|
||||||
|
applyClip()
|
||||||
|
}
|
||||||
|
|
||||||
// Flushes any pending batched geometry to SDL, then frees the scope's
|
// Flushes any pending batched geometry to SDL, then frees the scope's
|
||||||
// native buffer + clears the SDL clip. Call once per frame after the draw.
|
// native buffer + clears the SDL clip. Call once per frame after the draw.
|
||||||
fun finish() {
|
fun finish() {
|
||||||
|
fPendingClips.clear()
|
||||||
fScope.release()
|
fScope.release()
|
||||||
// Safety: an unbalanced save/restore must never leave the renderer pointed
|
// Safety: an unbalanced save/restore must never leave the renderer pointed
|
||||||
// at a scratch target when the frame is presented.
|
// at a scratch target when the frame is presented.
|
||||||
@@ -168,11 +298,14 @@ internal class Sdl3Canvas(
|
|||||||
// Offscreen canvas: the outermost save (CanvasDrawScope brackets its draw with
|
// Offscreen canvas: the outermost save (CanvasDrawScope brackets its draw with
|
||||||
// save/restore) switches the SDL render target to the ImageBitmap's texture.
|
// save/restore) switches the SDL render target to the ImageBitmap's texture.
|
||||||
if (fOffscreenTexture != null && fStack.isEmpty()) beginOffscreen()
|
if (fOffscreenTexture != null && fStack.isEmpty()) beginOffscreen()
|
||||||
fStack.addLast(State(fMa, fMb, fMc, fMd, fMe, fMf, fClip, fAlpha, fClipLayers.size))
|
fStack.addLast(State(fMa, fMb, fMc, fMd, fMe, fMf, fClip, fAlpha, fClipLayers.size, fPendingClips.size))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun restore() {
|
override fun restore() {
|
||||||
val vPrev = fStack.removeLastOrNull() ?: return
|
val vPrev = fStack.removeLastOrNull() ?: return
|
||||||
|
// Pending (never-realized) rounded clips opened inside this save frame:
|
||||||
|
// nothing was drawn through an offscreen for them - just drop them.
|
||||||
|
while (fPendingClips.size > vPrev.pendingClips) fPendingClips.removeLast()
|
||||||
// Composite + pop any offscreen rounded clips opened inside this save frame
|
// Composite + pop any offscreen rounded clips opened inside this save frame
|
||||||
// before restoring the plain state (each composite restores render target
|
// before restoring the plain state (each composite restores render target
|
||||||
// and SDL clip to what it was when the clip was opened).
|
// and SDL clip to what it was when the clip was opened).
|
||||||
@@ -269,6 +402,7 @@ internal class Sdl3Canvas(
|
|||||||
// treating Difference as intersect clipped the border TO the notch instead
|
// treating Difference as intersect clipped the border TO the notch instead
|
||||||
// (a floating line under the label, no outline anywhere else).
|
// (a floating line under the label, no outline anywhere else).
|
||||||
private fun clipRectDifference(inL: Float, inT: Float, inR: Float, inB: Float) {
|
private fun clipRectDifference(inL: Float, inT: Float, inR: Float, inB: Float) {
|
||||||
|
realizePendingClips()
|
||||||
val vDiff = mapRectAABB(inL, inT, inR, inB)
|
val vDiff = mapRectAABB(inL, inT, inR, inB)
|
||||||
val vRegion = fClip ?: intArrayOf(0, 0, fSize.width.toInt(), fSize.height.toInt())
|
val vRegion = fClip ?: intArrayOf(0, 0, fSize.width.toInt(), fSize.height.toInt())
|
||||||
if (fClipTargets == null || fSize.width < 1f || fSize.height < 1f ||
|
if (fClipTargets == null || fSize.width < 1f || fSize.height < 1f ||
|
||||||
@@ -375,20 +509,18 @@ internal class Sdl3Canvas(
|
|||||||
applyClip()
|
applyClip()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val vTarget = fClipTargets.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
|
// LAZY: record only — the rect part of the clip applies immediately,
|
||||||
if (vTarget == null) {
|
// the offscreen mask is opened by the first draw that actually needs
|
||||||
// Couldn't allocate a scratch target — degrade to a rectangular clip.
|
// it (admitDraw / realizePendingClips). The common clip+background
|
||||||
clipRect(inRoundRect.left, inRoundRect.top, inRoundRect.right, inRoundRect.bottom)
|
// idiom never does.
|
||||||
return
|
|
||||||
}
|
|
||||||
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
|
||||||
val vPrevTarget = SDL_GetRenderTarget(vRenderer)
|
|
||||||
val vPrevClip = fClip
|
val vPrevClip = fClip
|
||||||
SDL_SetRenderTarget(vRenderer, vTarget.reinterpret())
|
|
||||||
fClip = vRegion
|
fClip = vRegion
|
||||||
applyClip()
|
applyClip()
|
||||||
clearRegion(vRegion)
|
fPendingClips.addLast(PendingRoundClip(
|
||||||
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPrevClip, vRegion, vBbox, vDeviceRound))
|
vDeviceRound, inRoundRect, vRegion, vBbox, vPrevClip,
|
||||||
|
fMa, fMb, fMc, fMd, fMe, fMf,
|
||||||
|
fStack.size,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pops the top offscreen clip: flush its subtree, cut the rounded corners out
|
// Pops the top offscreen clip: flush its subtree, cut the rounded corners out
|
||||||
@@ -573,6 +705,7 @@ internal class Sdl3Canvas(
|
|||||||
inAmbientColor: androidx.compose.ui.graphics.Color,
|
inAmbientColor: androidx.compose.ui.graphics.Color,
|
||||||
inSpotColor: androidx.compose.ui.graphics.Color,
|
inSpotColor: androidx.compose.ui.graphics.Color,
|
||||||
) {
|
) {
|
||||||
|
realizePendingClips()
|
||||||
if (inElevationPx <= 0f) return
|
if (inElevationPx <= 0f) return
|
||||||
|
|
||||||
// Generic paths (CutCornerShape, GenericShape) don't 9-slice — blur the
|
// Generic paths (CutCornerShape, GenericShape) don't 9-slice — blur the
|
||||||
@@ -707,6 +840,7 @@ internal class Sdl3Canvas(
|
|||||||
// before re-rasterising a vector). Write transparent directly instead of the
|
// before re-rasterising a vector). Write transparent directly instead of the
|
||||||
// paint colour.
|
// paint colour.
|
||||||
if (paint.blendMode == BlendMode.Clear) {
|
if (paint.blendMode == BlendMode.Clear) {
|
||||||
|
realizePendingClips()
|
||||||
fScope.flush()
|
fScope.flush()
|
||||||
clearRegion(intArrayOf(
|
clearRegion(intArrayOf(
|
||||||
mapX(left, top).toInt(), mapY(left, top).toInt(),
|
mapX(left, top).toInt(), mapY(left, top).toInt(),
|
||||||
@@ -714,21 +848,75 @@ internal class Sdl3Canvas(
|
|||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (fPendingClips.isNotEmpty() && tryDrawRectUnderPendingClips(left, top, right, bottom, paint)) return
|
||||||
prep().rectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
prep().rectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The `clip(shape).background(color)` idiom: a FILL rect that fully covers
|
||||||
|
the innermost pending clip becomes a direct rounded-shape fill (with the
|
||||||
|
scope's fringe AA — no offscreen mask at all). A rect fully INSIDE every
|
||||||
|
pending clip draws as-is. Returns true when the draw has been emitted. */
|
||||||
|
private fun tryDrawRectUnderPendingClips(inL: Float, inT: Float, inR: Float, inB: Float, inPaint: Paint): Boolean {
|
||||||
|
val vBox = deviceAabb(inL, inT, inR, inB)
|
||||||
|
val vInner = fPendingClips.last()
|
||||||
|
// Cover case: fill paint, no stroke, rect covers the whole clip outline,
|
||||||
|
// affine unchanged since the clip was pushed (local-space re-emission),
|
||||||
|
// uniform corner radii (the scope's roundRectCore takes one radius), and
|
||||||
|
// the clip outline itself contained in every OUTER pending clip.
|
||||||
|
val vLr = vInner.localRound
|
||||||
|
val vUniform = vLr.topLeftCornerRadius.x == vLr.topLeftCornerRadius.y &&
|
||||||
|
vLr.topLeftCornerRadius == vLr.topRightCornerRadius &&
|
||||||
|
vLr.topLeftCornerRadius == vLr.bottomRightCornerRadius &&
|
||||||
|
vLr.topLeftCornerRadius == vLr.bottomLeftCornerRadius
|
||||||
|
val vAffineUnchanged = vInner.ma == fMa && vInner.mb == fMb && vInner.mc == fMc &&
|
||||||
|
vInner.md == fMd && vInner.me == fMe && vInner.mf == fMf
|
||||||
|
if (inPaint.style == PaintingStyle.Fill && inPaint.blendMode == BlendMode.SrcOver &&
|
||||||
|
vUniform && vAffineUnchanged &&
|
||||||
|
vBox[0] <= vInner.bbox[0] && vBox[1] <= vInner.bbox[1] &&
|
||||||
|
vBox[2] >= vInner.bbox[2] && vBox[3] >= vInner.bbox[3]
|
||||||
|
) {
|
||||||
|
var vOutersContain = true
|
||||||
|
for (vPending in fPendingClips) {
|
||||||
|
if (vPending === vInner) continue
|
||||||
|
if (!roundRectContainsAabb(vPending.deviceRound, vInner.deviceRound.left, vInner.deviceRound.top, vInner.deviceRound.right, vInner.deviceRound.bottom)) {
|
||||||
|
vOutersContain = false; break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vOutersContain) {
|
||||||
|
prep().roundRectCore(
|
||||||
|
brushFor(inPaint),
|
||||||
|
Offset(vLr.left, vLr.top), Size(vLr.width, vLr.height),
|
||||||
|
vLr.topLeftCornerRadius.x,
|
||||||
|
(inPaint.alpha * fAlpha), Fill,
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Containment: inside every pending outline → the active rect clip is enough.
|
||||||
|
for (vPending in fPendingClips) {
|
||||||
|
if (!roundRectContainsAabb(vPending.deviceRound, vBox[0], vBox[1], vBox[2], vBox[3])) {
|
||||||
|
realizePendingClips()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
override fun drawRoundRect(
|
override fun drawRoundRect(
|
||||||
left: Float, top: Float, right: Float, bottom: Float,
|
left: Float, top: Float, right: Float, bottom: Float,
|
||||||
radiusX: Float, radiusY: Float, paint: Paint,
|
radiusX: Float, radiusY: Float, paint: Paint,
|
||||||
) {
|
) {
|
||||||
|
admitDraw(left, top, right, bottom)
|
||||||
prep().roundRectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), radiusX, (paint.alpha * fAlpha), styleFor(paint))
|
prep().roundRectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), radiusX, (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
||||||
|
admitDraw(left, top, right, bottom)
|
||||||
prep().ovalCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
prep().ovalCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
|
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
|
||||||
|
admitDraw(center.x - radius, center.y - radius, center.x + radius, center.y + radius)
|
||||||
prep().circleCore(brushFor(paint), radius, center, (paint.alpha * fAlpha), styleFor(paint))
|
prep().circleCore(brushFor(paint), radius, center, (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,14 +924,17 @@ internal class Sdl3Canvas(
|
|||||||
left: Float, top: Float, right: Float, bottom: Float,
|
left: Float, top: Float, right: Float, bottom: Float,
|
||||||
startAngle: Float, sweepAngle: Float, useCenter: Boolean, paint: Paint,
|
startAngle: Float, sweepAngle: Float, useCenter: Boolean, paint: Paint,
|
||||||
) {
|
) {
|
||||||
|
realizePendingClips()
|
||||||
prep().arcCore(brushFor(paint), startAngle, sweepAngle, useCenter, Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
prep().arcCore(brushFor(paint), startAngle, sweepAngle, useCenter, Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
|
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
|
||||||
|
realizePendingClips()
|
||||||
prep().lineCore(brushFor(paint), p1, p2, paint.strokeWidth, paint.strokeCap, (paint.alpha * fAlpha))
|
prep().lineCore(brushFor(paint), p1, p2, paint.strokeWidth, paint.strokeCap, (paint.alpha * fAlpha))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun drawPath(path: ComposePath, paint: Paint) {
|
override fun drawPath(path: ComposePath, paint: Paint) {
|
||||||
|
realizePendingClips()
|
||||||
prep().pathCore(path, brushFor(paint), (paint.alpha * fAlpha), styleFor(paint))
|
prep().pathCore(path, brushFor(paint), (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,6 +959,12 @@ internal class Sdl3Canvas(
|
|||||||
inBaseItalic: Boolean,
|
inBaseItalic: Boolean,
|
||||||
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
|
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
|
||||||
) {
|
) {
|
||||||
|
// Small centred labels (a popped bubble's "pop") usually sit fully
|
||||||
|
// inside a rounded clip — gate on containment like other draws, with
|
||||||
|
// a 2px margin for glyph overhang / AA bleed, instead of paying a
|
||||||
|
// mask pass per text run.
|
||||||
|
admitDraw(inX - 2f, inY - 2f, inX + inBoxWidth + 2f, inY + inBoxHeight + 2f)
|
||||||
|
com.compose.sdl.graphics.DrawStats.textDraws++
|
||||||
fScope.flush()
|
fScope.flush()
|
||||||
// Paragraph-level decoration bits forwarded to every wrapped line.
|
// Paragraph-level decoration bits forwarded to every wrapped line.
|
||||||
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
|
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
|
||||||
@@ -783,26 +980,25 @@ internal class Sdl3Canvas(
|
|||||||
// node's real box. The per-line path below centres within a lineHeight band
|
// node's real box. The per-line path below centres within a lineHeight band
|
||||||
// (1.2 em for Material Symbols), taller than the size-clamped icon node,
|
// (1.2 em for Material Symbols), taller than the size-clamped icon node,
|
||||||
// which pushed every icon ~0.1 em below centre.
|
// which pushed every icon ~0.1 em below centre.
|
||||||
// Device-space scale for GLYPHS: the pen position already maps through
|
// Text is rasterised at its LOGICAL font size (stable glyph-texture cache
|
||||||
// the affine, but font size and centering boxes must scale with it too
|
// + stable hinting) and blit 1:1 at the affine-mapped pen position; the
|
||||||
// or text inside a graphicsLayer(scale) renders at full size, off
|
// layer's scale reaches position but NOT glyph size. Re-rasterising at a
|
||||||
// centre (JVM/Skia scales glyphs with the canvas). Wrap stays in LOCAL
|
// per-frame device size (an earlier attempt) made text under an animated
|
||||||
// units below so line breaks match what layout measured.
|
// layer scale — every Material popup/dropdown enter transition
|
||||||
val vTextScaleX = sqrt(fMa * fMa + fMb * fMb)
|
// (Menu.kt scaleX/scaleY) — step through integer sizes with visible
|
||||||
val vTextScaleY = sqrt(fMc * fMc + fMd * fMd)
|
// size/weight jumps, since SDL3_ttf re-hints at each size. Smoothly
|
||||||
val vDeviceFontPx =
|
// scaling glyphs would need blit-scaling a logical-size rasterisation
|
||||||
if (vTextScaleY == 1f) inFontSizePx
|
// (JVM/Skia GPU-scales the layer) — a future text-renderer enhancement;
|
||||||
else (inFontSizePx * vTextScaleY).toInt().coerceAtLeast(1)
|
// until then logical-size + no glyph scale is the stable choice.
|
||||||
|
|
||||||
if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
|
if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
|
||||||
vTr.drawText(
|
vTr.drawText(
|
||||||
inText = inText,
|
inText = inText,
|
||||||
inX = mapX(inX, inY).toInt(),
|
inX = mapX(inX, inY).toInt(),
|
||||||
inY = mapY(inX, inY).toInt(),
|
inY = mapY(inX, inY).toInt(),
|
||||||
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
|
inBoxWidth = inBoxWidth.toInt(),
|
||||||
inBoxHeight = (inBoxHeight * vTextScaleY).toInt(),
|
inBoxHeight = inBoxHeight.toInt(),
|
||||||
inColor = vColor,
|
inColor = vColor,
|
||||||
inFontSize = vDeviceFontPx,
|
inFontSize = inFontSizePx,
|
||||||
inAlign = inTextAlign,
|
inAlign = inTextAlign,
|
||||||
inFontFamily = inFontFamily,
|
inFontFamily = inFontFamily,
|
||||||
inFontVariations = inFontVariations,
|
inFontVariations = inFontVariations,
|
||||||
@@ -845,10 +1041,10 @@ internal class Sdl3Canvas(
|
|||||||
// scale with the layer. Rotation still only repositions.
|
// scale with the layer. Rotation still only repositions.
|
||||||
inX = mapX(inX, vLineY).toInt(),
|
inX = mapX(inX, vLineY).toInt(),
|
||||||
inY = mapY(inX, vLineY).toInt(),
|
inY = mapY(inX, vLineY).toInt(),
|
||||||
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
|
inBoxWidth = inBoxWidth.toInt(),
|
||||||
inBoxHeight = (vLineH * vTextScaleY).toInt(),
|
inBoxHeight = vLineH.toInt(),
|
||||||
inColor = vColor,
|
inColor = vColor,
|
||||||
inFontSize = vDeviceFontPx,
|
inFontSize = inFontSizePx,
|
||||||
inAlign = inTextAlign,
|
inAlign = inTextAlign,
|
||||||
inFontFamily = inFontFamily,
|
inFontFamily = inFontFamily,
|
||||||
inFontVariations = inFontVariations,
|
inFontVariations = inFontVariations,
|
||||||
@@ -876,6 +1072,7 @@ internal class Sdl3Canvas(
|
|||||||
inContentScale: androidx.compose.ui.layout.ContentScale,
|
inContentScale: androidx.compose.ui.layout.ContentScale,
|
||||||
inAlpha: Float,
|
inAlpha: Float,
|
||||||
) {
|
) {
|
||||||
|
admitDraw(inX, inY, inX + inWidth, inY + inHeight)
|
||||||
fScope.flush()
|
fScope.flush()
|
||||||
// Map the origin through the affine and scale the size by the matrix's axis
|
// Map the origin through the affine and scale the size by the matrix's axis
|
||||||
// magnitudes (rotation contributes 1, so it only repositions the blit — the
|
// magnitudes (rotation contributes 1, so it only repositions the blit — the
|
||||||
@@ -894,6 +1091,7 @@ internal class Sdl3Canvas(
|
|||||||
|
|
||||||
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
|
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
|
||||||
val vBmp = image as? SdlImageBitmap ?: return
|
val vBmp = image as? SdlImageBitmap ?: return
|
||||||
|
admitDraw(topLeftOffset.x, topLeftOffset.y, topLeftOffset.x + vBmp.width, topLeftOffset.y + vBmp.height)
|
||||||
drawImageRect(
|
drawImageRect(
|
||||||
image,
|
image,
|
||||||
androidx.compose.ui.unit.IntOffset.Zero,
|
androidx.compose.ui.unit.IntOffset.Zero,
|
||||||
@@ -916,7 +1114,9 @@ internal class Sdl3Canvas(
|
|||||||
dstSize: androidx.compose.ui.unit.IntSize,
|
dstSize: androidx.compose.ui.unit.IntSize,
|
||||||
paint: Paint,
|
paint: Paint,
|
||||||
) {
|
) {
|
||||||
|
realizePendingClips()
|
||||||
val vTex = (image as? SdlImageBitmap)?.texture ?: return
|
val vTex = (image as? SdlImageBitmap)?.texture ?: return
|
||||||
|
com.compose.sdl.graphics.DrawStats.imageBlits++
|
||||||
// Commit pending frame geometry and re-assert this canvas's target + clip
|
// Commit pending frame geometry and re-assert this canvas's target + clip
|
||||||
// (an offscreen render just borrowed the render target).
|
// (an offscreen render just borrowed the render target).
|
||||||
fScope.flush()
|
fScope.flush()
|
||||||
|
|||||||
+60
-9
@@ -141,6 +141,8 @@ internal class Sdl3DrawScope(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
SDL_RenderGeometry(fRenderer.reinterpret(), null, fBatch, fBatchCount, null, 0)
|
SDL_RenderGeometry(fRenderer.reinterpret(), null, fBatch, fBatchCount, null, 0)
|
||||||
|
com.compose.sdl.graphics.DrawStats.geometrySubmits++
|
||||||
|
com.compose.sdl.graphics.DrawStats.vertices += fBatchCount
|
||||||
fBatchCount = 0
|
fBatchCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +245,10 @@ internal class Sdl3DrawScope(
|
|||||||
val vB = vT + size.height
|
val vB = vT + size.height
|
||||||
when (style) {
|
when (style) {
|
||||||
Fill -> {
|
Fill -> {
|
||||||
emitQuad(vL, vT, vR, vT, vR, vB, vL, vB, vSampler)
|
// Gradient fills need interior samples (see emitRectMesh); solid
|
||||||
|
// fills are a single flat quad.
|
||||||
|
if (brush is SolidColor) emitQuad(vL, vT, vR, vT, vR, vB, vL, vB, vSampler)
|
||||||
|
else emitRectMesh(vL, vT, vR, vB, vSampler)
|
||||||
// Axis-aligned rects are pixel-crisp and need no AA. When the affine
|
// Axis-aligned rects are pixel-crisp and need no AA. When the affine
|
||||||
// rotates/shears them, the edges become diagonal — feather each edge
|
// rotates/shears them, the edges become diagonal — feather each edge
|
||||||
// outward (normals in local space; the matrix orients them on screen).
|
// outward (normals in local space; the matrix orients them on screen).
|
||||||
@@ -524,20 +529,30 @@ internal class Sdl3DrawScope(
|
|||||||
val vY = fOriginY + topLeft.y
|
val vY = fOriginY + topLeft.y
|
||||||
val vW = size.width
|
val vW = size.width
|
||||||
val vH = size.height
|
val vH = size.height
|
||||||
|
// Gradient fills sample per-vertex, so the interior must be meshed
|
||||||
|
// (corner-only sampling renders radial/sweep flat — see emitRectMesh);
|
||||||
|
// solid fills stay single quads.
|
||||||
|
val vGrad = brush !is SolidColor
|
||||||
if (vR <= 0f) {
|
if (vR <= 0f) {
|
||||||
// Trivial: just two triangles.
|
if (vGrad) emitRectMesh(vX, vY, vX + vW, vY + vH, vSampler)
|
||||||
emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
|
else emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Body in 3 strips: middle (full width × inner height), top edge,
|
// Body in 3 strips: middle (full width × inner height), top edge,
|
||||||
// bottom edge — plus the 4 corner arcs.
|
// bottom edge — plus the 4 corner arcs.
|
||||||
if (style == Fill) {
|
if (style == Fill) {
|
||||||
|
if (vGrad) {
|
||||||
|
emitRectMesh(vX, vY + vR, vX + vW, vY + vH - vR, vSampler) // middle
|
||||||
|
emitRectMesh(vX + vR, vY, vX + vW - vR, vY + vR, vSampler) // top edge
|
||||||
|
emitRectMesh(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH, vSampler) // bottom edge
|
||||||
|
} else {
|
||||||
// Middle strip
|
// Middle strip
|
||||||
emitQuad(vX, vY + vR, vX + vW, vY + vR, vX + vW, vY + vH - vR, vX, vY + vH - vR, vSampler)
|
emitQuad(vX, vY + vR, vX + vW, vY + vR, vX + vW, vY + vH - vR, vX, vY + vH - vR, vSampler)
|
||||||
// Top edge (between left+right corners)
|
// Top edge (between left+right corners)
|
||||||
emitQuad(vX + vR, vY, vX + vW - vR, vY, vX + vW - vR, vY + vR, vX + vR, vY + vR, vSampler)
|
emitQuad(vX + vR, vY, vX + vW - vR, vY, vX + vW - vR, vY + vR, vX + vR, vY + vR, vSampler)
|
||||||
// Bottom edge
|
// Bottom edge
|
||||||
emitQuad(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH - vR, vX + vW - vR, vY + vH, vX + vR, vY + vH, vSampler)
|
emitQuad(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH - vR, vX + vW - vR, vY + vH, vX + vR, vY + vH, vSampler)
|
||||||
|
}
|
||||||
// 4 corner fills — segment count adapts to the corner radius.
|
// 4 corner fills — segment count adapts to the corner radius.
|
||||||
val vSeg = arcSegments(90f, vR)
|
val vSeg = arcSegments(90f, vR)
|
||||||
emitFilledArc(vX + vR, vY + vR, vR, vR, 180f, 90f, false, vSeg, vSampler)
|
emitFilledArc(vX + vR, vY + vR, vR, vR, 180f, 90f, false, vSeg, vSampler)
|
||||||
@@ -854,6 +869,34 @@ internal class Sdl3DrawScope(
|
|||||||
emitTri(ax, ay, cx, cy, dx, dy, inSampler)
|
emitTri(ax, ay, cx, cy, dx, dy, inSampler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Axis-aligned rect subdivided into a grid, so a per-vertex sampler is
|
||||||
|
// evaluated across the INTERIOR, not just the 4 corners. Corner-only
|
||||||
|
// sampling renders radial/sweep gradients flat (a square's corners are all
|
||||||
|
// equidistant from its centre → identical t → solid fill) and clips
|
||||||
|
// non-uniform multi-stop linear ramps. SDL interpolates linearly within
|
||||||
|
// each cell, so ~16 cells/axis tracks the gradient curve closely. Solid
|
||||||
|
// fills never come here (they use emitQuad — one quad).
|
||||||
|
private fun emitRectMesh(
|
||||||
|
inL: Float, inT: Float, inR: Float, inB: Float, inSampler: Sampler,
|
||||||
|
) {
|
||||||
|
// ~one cell per 8 device px per axis (1..24), so thin edge strips stay
|
||||||
|
// cheap while large fills get enough interior samples for the gradient.
|
||||||
|
val vCols = (((inR - inL) / 8f).toInt()).coerceIn(1, 24)
|
||||||
|
val vRows = (((inB - inT) / 8f).toInt()).coerceIn(1, 24)
|
||||||
|
val vW = (inR - inL) / vCols
|
||||||
|
val vH = (inB - inT) / vRows
|
||||||
|
for (vRow in 0 until vRows) {
|
||||||
|
val vY0 = inT + vRow * vH
|
||||||
|
val vY1 = if (vRow == vRows - 1) inB else vY0 + vH
|
||||||
|
for (vCol in 0 until vCols) {
|
||||||
|
val vX0 = inL + vCol * vW
|
||||||
|
val vX1 = if (vCol == vCols - 1) inR else vX0 + vW
|
||||||
|
emitTri(vX0, vY0, vX1, vY0, vX1, vY1, inSampler)
|
||||||
|
emitTri(vX0, vY0, vX1, vY1, vX0, vY1, inSampler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun emitTri(
|
private fun emitTri(
|
||||||
ax: Float, ay: Float, bx: Float, by: Float, cx: Float, cy: Float,
|
ax: Float, ay: Float, bx: Float, by: Float, cx: Float, cy: Float,
|
||||||
inSampler: Sampler,
|
inSampler: Sampler,
|
||||||
@@ -893,7 +936,13 @@ internal class Sdl3DrawScope(
|
|||||||
// through the current affine so scale/rotate reach the GPU; the colour is
|
// through the current affine so scale/rotate reach the GPU; the colour is
|
||||||
// sampled at the pre-transform point so a gradient rides its shape.
|
// sampled at the pre-transform point so a gradient rides its shape.
|
||||||
private fun writeVertex(inX: Float, inY: Float, inColor: ComposeColor, inAlphaScale: Float = 1f) {
|
private fun writeVertex(inX: Float, inY: Float, inColor: ComposeColor, inAlphaScale: Float = 1f) {
|
||||||
if (fBatchCount >= kBatchCapacity) flush()
|
// Auto-flush ONLY at a triangle boundary: SDL_RenderGeometry renders
|
||||||
|
// floor(count/3) triangles and silently drops the tail, so flushing
|
||||||
|
// mid-triangle tears it AND misaligns every following triangle in the
|
||||||
|
// new batch (seen as ~350-degree pac-man circles and glitch shapes
|
||||||
|
// once a frame's geometry outgrew one batch - e.g. 84 unclipped
|
||||||
|
// drawBehind bubbles). kBatchCapacity is a multiple of 3.
|
||||||
|
if (fBatchCount >= kBatchCapacity && fBatchCount % 3 == 0) flush()
|
||||||
val vBase = fBatchCount * kFloatsPerVertex
|
val vBase = fBatchCount * kFloatsPerVertex
|
||||||
fVertexData[vBase + 0] = fMa * inX + fMc * inY + fMe
|
fVertexData[vBase + 0] = fMa * inX + fMc * inY + fMe
|
||||||
fVertexData[vBase + 1] = fMb * inX + fMd * inY + fMf
|
fVertexData[vBase + 1] = fMb * inX + fMd * inY + fMf
|
||||||
@@ -914,11 +963,13 @@ private const val kAaFeather: Float = 1.0f
|
|||||||
private const val kAaHalf: Float = kAaFeather * 0.5f
|
private const val kAaHalf: Float = kAaFeather * 0.5f
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Batch capacity — 8192 vertices = ~2730 triangles per submission. At
|
// Batch capacity — 8190 vertices = 2730 triangles per submission (kept a
|
||||||
// 64 segments per full circle that's room for ~21 full-circle filled
|
// MULTIPLE OF 3: the auto-flush in writeVertex only fires on triangle
|
||||||
// shapes per Canvas{} before any flush. Bigger gives fewer GPU
|
// boundaries, so the cap must land on one). At 64 segments per full circle
|
||||||
// submissions; smaller saves RAM. ~128 KB at 16 bytes per SDL_Vertex.
|
// that's room for ~21 full-circle filled shapes per Canvas{} before any
|
||||||
private const val kBatchCapacity: Int = 8192
|
// flush. Bigger gives fewer GPU submissions; smaller saves RAM. ~128 KB at
|
||||||
|
// 16 bytes per SDL_Vertex.
|
||||||
|
private const val kBatchCapacity: Int = 8190
|
||||||
|
|
||||||
// Floats per SDL_Vertex: position(x,y) + color(r,g,b,a) + tex_coord(x,y), tightly
|
// Floats per SDL_Vertex: position(x,y) + color(r,g,b,a) + tex_coord(x,y), tightly
|
||||||
// packed. Used to stage vertices in a Kotlin FloatArray and memcpy them across.
|
// packed. Used to stage vertices in a Kotlin FloatArray and memcpy them across.
|
||||||
|
|||||||
+49
-7
@@ -29,6 +29,34 @@ internal var currentMainCanvas: Sdl3Canvas? = null
|
|||||||
// An ImageBitmap backed by an SDL render-target texture. The vector rasterises
|
// An ImageBitmap backed by an SDL render-target texture. The vector rasterises
|
||||||
// into the texture (via a Canvas returned by the factory) and Sdl3Canvas.drawImageRect
|
// into the texture (via a Canvas returned by the factory) and Sdl3Canvas.drawImageRect
|
||||||
// blits it back — with the Icon tint applied through SDL_SetTextureColorMod.
|
// blits it back — with the Icon tint applied through SDL_SetTextureColorMod.
|
||||||
|
// Mutable native handles held BY REFERENCE so a Cleaner can free them without
|
||||||
|
// capturing the (collectable) SdlImageBitmap. Both the explicit close() and the
|
||||||
|
// GC Cleaner enqueue release of whatever is still here — the first to run nulls
|
||||||
|
// the fields, so the other is a no-op (they can't overlap: close() only runs
|
||||||
|
// while the bitmap is reachable, the Cleaner only after it isn't).
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private class SdlBitmapHandles(
|
||||||
|
var texture: COpaquePointer?,
|
||||||
|
var surface: CPointer<SDL_Surface>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Enqueue destruction of the handles' current contents on the main thread
|
||||||
|
(SDL calls aren't thread-safe; the Cleaner runs on a GC worker). Idempotent:
|
||||||
|
clears the fields so a second call frees nothing. Top-level so the Cleaner
|
||||||
|
block never captures the bitmap. */
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun enqueueRelease(inHandles: SdlBitmapHandles) {
|
||||||
|
val vTex = inHandles.texture
|
||||||
|
val vSurf = inHandles.surface
|
||||||
|
if (vTex == null && vSurf == null) return
|
||||||
|
inHandles.texture = null
|
||||||
|
inHandles.surface = null
|
||||||
|
com.compose.sdl.graphics.NativeReleaseQueue.enqueue {
|
||||||
|
if (vSurf != null) SDL_DestroySurface(vSurf)
|
||||||
|
if (vTex != null) SDL_DestroyTexture(vTex.reinterpret())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
internal class SdlImageBitmap(
|
internal class SdlImageBitmap(
|
||||||
private val fRenderer: COpaquePointer,
|
private val fRenderer: COpaquePointer,
|
||||||
@@ -41,19 +69,28 @@ internal class SdlImageBitmap(
|
|||||||
// Sdl3EncodedImageDecoder); converted to a texture on the first draw.
|
// Sdl3EncodedImageDecoder); converted to a texture on the first draw.
|
||||||
// null → create a render TARGET for the vector-rasterisation path below
|
// null → create a render TARGET for the vector-rasterisation path below
|
||||||
// (that path always constructs on the main thread).
|
// (that path always constructs on the main thread).
|
||||||
private var fDecodedSurface: CPointer<SDL_Surface>? = null,
|
fDecodedSurface: CPointer<SDL_Surface>? = null,
|
||||||
) : ImageBitmap {
|
) : ImageBitmap {
|
||||||
|
|
||||||
// RGBA render-target texture, premultiplied blend for compositing back (content
|
// RGBA render-target texture, premultiplied blend for compositing back (content
|
||||||
// is drawn over a transparent clear with ordinary BLEND, leaving premultiplied
|
// is drawn over a transparent clear with ordinary BLEND, leaving premultiplied
|
||||||
// colours — see Sdl3ClipTargets for the same reasoning).
|
// colours — see Sdl3ClipTargets for the same reasoning).
|
||||||
private var fTexture: COpaquePointer? = if (fDecodedSurface != null) null else SDL_CreateTexture(
|
private val fHandles = SdlBitmapHandles(
|
||||||
|
texture = if (fDecodedSurface != null) null else SDL_CreateTexture(
|
||||||
fRenderer.reinterpret(),
|
fRenderer.reinterpret(),
|
||||||
SDL_PIXELFORMAT_RGBA32,
|
SDL_PIXELFORMAT_RGBA32,
|
||||||
SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET,
|
SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET,
|
||||||
maxOf(1, width),
|
maxOf(1, width),
|
||||||
maxOf(1, height),
|
maxOf(1, height),
|
||||||
)?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED) }
|
)?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED) },
|
||||||
|
surface = fDecodedSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
// GC backstop: if nothing calls close(), free the native handles when the
|
||||||
|
// bitmap is collected (deferred to the main thread via the queue). Ownership
|
||||||
|
// via close() is the primary path; this catches leaks.
|
||||||
|
@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
|
||||||
|
private val fCleaner = kotlin.native.ref.createCleaner(fHandles) { enqueueRelease(it) }
|
||||||
|
|
||||||
// SDL renderer calls are NOT thread-safe, and the resources pipeline
|
// SDL renderer calls are NOT thread-safe, and the resources pipeline
|
||||||
// decodes on Dispatchers.Default workers — so the decode path hands over a
|
// decodes on Dispatchers.Default workers — so the decode path hands over a
|
||||||
@@ -63,15 +100,20 @@ internal class SdlImageBitmap(
|
|||||||
// render-target path above.
|
// render-target path above.
|
||||||
val texture: COpaquePointer?
|
val texture: COpaquePointer?
|
||||||
get() {
|
get() {
|
||||||
fDecodedSurface?.let { vSurface ->
|
fHandles.surface?.let { vSurface ->
|
||||||
fTexture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface)
|
fHandles.texture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface)
|
||||||
?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND) }
|
?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND) }
|
||||||
SDL_DestroySurface(vSurface)
|
SDL_DestroySurface(vSurface)
|
||||||
fDecodedSurface = null
|
fHandles.surface = null
|
||||||
}
|
}
|
||||||
return fTexture
|
return fHandles.texture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Free the texture/surface now (deferred to the main thread). Call on
|
||||||
|
cache eviction / removeMemoryResource so native memory releases promptly
|
||||||
|
instead of waiting for a GC. The Cleaner remains as a backstop. */
|
||||||
|
fun close() = enqueueRelease(fHandles)
|
||||||
|
|
||||||
override fun readPixels(
|
override fun readPixels(
|
||||||
buffer: IntArray,
|
buffer: IntArray,
|
||||||
startX: Int,
|
startX: Int,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import kotlin.experimental.ExperimentalNativeApi
|
|||||||
|
|
||||||
internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge?
|
internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge?
|
||||||
|
|
||||||
fun rendererPreferredGpuMode(): GpuMode {
|
actual fun rendererPreferredGpuMode(): GpuMode {
|
||||||
@OptIn(ExperimentalNativeApi::class)
|
@OptIn(ExperimentalNativeApi::class)
|
||||||
return when (Platform.osFamily) {
|
return when (Platform.osFamily) {
|
||||||
OsFamily.MACOSX -> GpuMode.Skia.Metal
|
OsFamily.MACOSX -> GpuMode.Skia.Metal
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.skia.SkiaRenderBackend
|
|||||||
SDL3 module's createRenderBackend / rendererPreferredGpuMode in the same
|
SDL3 module's createRenderBackend / rendererPreferredGpuMode in the same
|
||||||
package; exactly one renderer module is included per target. Rejects
|
package; exactly one renderer module is included per target. Rejects
|
||||||
Sdl3.* since this module has no SDL3_ttf / SDL3_image. */
|
Sdl3.* since this module has no SDL3_ttf / SDL3_image. */
|
||||||
fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
|
actual fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
|
||||||
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||||
if (vResolved is GpuMode.Sdl3) {
|
if (vResolved is GpuMode.Sdl3) {
|
||||||
error("Sdl3.* modes aren't available in a Skia build — rerun with -Prenderer=sdl3")
|
error("Sdl3.* modes aren't available in a Skia build — rerun with -Prenderer=sdl3")
|
||||||
|
|||||||
@@ -42,55 +42,9 @@ internal fun GraphicsLayerScreen() {
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
ScreenTitle(
|
ScreenTitle(
|
||||||
"Modifier.graphicsLayer",
|
"Modifier.graphicsLayer",
|
||||||
"2D transform (scale / rotation / translation) + alpha + optional cacheKey opt-in. " +
|
"2D transform (scale / rotation / translation) + alpha, applied to a subtree.",
|
||||||
"When cacheKey is set, the renderer renders the subtree into an offscreen target once " +
|
|
||||||
"and reuses it while the key compares equal — change the key to invalidate.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Section(
|
|
||||||
"Cached subtree (cacheKey)",
|
|
||||||
"Identical to a fresh-drawn row visually, but the subtree is rendered once and reused while " +
|
|
||||||
"cacheKey doesn't change. Useful for static screens — no per-frame redraw of the chrome.",
|
|
||||||
) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
for (vI in 0..3) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.graphicsLayer(),
|
|
||||||
) {
|
|
||||||
Canvas(modifier = Modifier.size(64.dp)) {
|
|
||||||
drawCircle(color = vPrimary, radius = 22f, center = Offset(32f, 32f))
|
|
||||||
drawCircle(color = vSecondary, radius = 12f, center = Offset(32f, 32f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text("(cached)", color = vPrimary, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Combined (cache + rotation)", "Cached subtree blitted with live rotation around its centre.") {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
for (vI in 0..4) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.graphicsLayer(
|
|
||||||
rotationZ = vSpin + vI * 30f,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Canvas(modifier = Modifier.size(64.dp)) {
|
|
||||||
drawRect(
|
|
||||||
color = if (vI % 2 == 0) vPrimary else vSecondary,
|
|
||||||
topLeft = Offset(10f, 10f),
|
|
||||||
size = Size(44f, 44f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Rotation (live)", "Each tile holds the same shape rotated at multiples of the current spin angle.") {
|
Section("Rotation (live)", "Each tile holds the same shape rotated at multiples of the current spin angle.") {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
for (vI in 0..4) {
|
for (vI in 0..4) {
|
||||||
|
|||||||
@@ -1,16 +1,46 @@
|
|||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.ImageComposeScene
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import demo.registry.allCategories
|
||||||
import demo.shell.App
|
import demo.shell.App
|
||||||
|
import org.jetbrains.skia.EncodedImageFormat
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
// The JVM comparison app's entry point. Runs the SAME shared App() shell + Core /
|
// The JVM comparison app: the SAME shared App() + screens as :demo, on upstream
|
||||||
// Material 3 screens (expressive included) as :demo, but on Compose Desktop (JVM)
|
// Compose Desktop. Interactive by default; a headless screenshot mode drives
|
||||||
// against upstream org.jetbrains.compose. Compare side-by-side with
|
// the parity harness (scripts/parity — compares each screen native vs jvm).
|
||||||
// `:demo:runDebugExecutable<host>`.
|
//
|
||||||
fun main() = application {
|
// --screenshot-all=<dir> render every registered screen to <dir>/<Name>.png
|
||||||
|
// --width / --height viewport size (default 1000 / 700)
|
||||||
|
//
|
||||||
|
// The single-screen wrapper MIRRORS MainNative's --screen path (dark theme,
|
||||||
|
// verticalScroll + 24dp padding) so layout constraints match the native
|
||||||
|
// screenshots pixel-for-pixel.
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
val screenshotDir = args.firstOrNull { it.startsWith("--screenshot-all=") }?.substringAfter('=')
|
||||||
|
if (screenshotDir != null) {
|
||||||
|
screenshotAllScreens(
|
||||||
|
outDir = File(screenshotDir),
|
||||||
|
width = args.intArg("--width", 1000),
|
||||||
|
height = args.intArg("--height", 700),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
application {
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
title = "ComposeDesktopNative — JVM (upstream Compose)",
|
title = "ComposeDesktopNative — JVM (upstream Compose)",
|
||||||
@@ -20,4 +50,43 @@ fun main() = application {
|
|||||||
App(isJvm = true)
|
App(isJvm = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Array<String>.intArg(name: String, default: Int): Int =
|
||||||
|
firstOrNull { it.startsWith("$name=") }?.substringAfter('=')?.toIntOrNull() ?: default
|
||||||
|
|
||||||
|
/* Render each registered screen headlessly (density 1 to match the native
|
||||||
|
physical-pixel screenshots) and write a PNG per screen. */
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
private fun screenshotAllScreens(outDir: File, width: Int, height: Int) {
|
||||||
|
outDir.mkdirs()
|
||||||
|
val screens = allCategories().flatMap { it.screens }.distinctBy { it.name }
|
||||||
|
for (screen in screens) {
|
||||||
|
val scene = ImageComposeScene(width, height, density = Density(1f)) {
|
||||||
|
ScreenHost { screen.content() }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val image = scene.render()
|
||||||
|
val png = image.encodeToData(EncodedImageFormat.PNG) ?: continue
|
||||||
|
File(outDir, "${screen.name}.png").writeBytes(png.bytes)
|
||||||
|
} finally {
|
||||||
|
scene.close()
|
||||||
|
}
|
||||||
|
println("jvm screenshot: ${screen.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same wrapper as MainNative's --screen path. */
|
||||||
|
@Composable
|
||||||
|
private fun ScreenHost(content: @Composable () -> Unit) {
|
||||||
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,42 @@ kotlin {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin can also be applied to a single module's `build.gradle.kts` instead
|
### Settings-wide or per-module
|
||||||
of settings (it then bridges only that module's configurations).
|
|
||||||
|
The plugin applies at either level:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// settings.gradle.kts — every module of the build
|
||||||
|
plugins { id("com.bitsycore.compose-desktop-native.bridge") version "<v>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts — this module only (compose-plugin style)
|
||||||
|
plugins { id("com.bitsycore.compose-desktop-native.bridge") }
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the difference between the two settings blocks: a
|
||||||
|
`pluginManagement { plugins { id(...) version ... } }` entry only PINS the
|
||||||
|
version (so module-level applications can omit it); the top-level
|
||||||
|
`plugins { }` block in settings is what actually applies it build-wide.
|
||||||
|
Rule of thumb: single app module → apply in the module; multi-module builds →
|
||||||
|
apply once in settings.
|
||||||
|
|
||||||
|
## compose.desktop.native — the application block for native
|
||||||
|
|
||||||
|
The native counterpart of `compose.desktop { application { mainClass } }`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
compose.desktop {
|
||||||
|
application { mainClass = "app.MainJvmKt" } // upstream jvm
|
||||||
|
native { entryPoint = "app.main" } // compose-desktop-native
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Declares an executable with that entry point on every Kotlin/Native desktop
|
||||||
|
target — no `targets.withType<KotlinNativeTarget> { binaries.executable { … } }`
|
||||||
|
boilerplate. Targets that already declare an executable are left untouched,
|
||||||
|
so manual configuration (extra linker flags, custom build types) still wins.
|
||||||
|
|
||||||
## composeResources — zero setup
|
## composeResources — zero setup
|
||||||
|
|
||||||
|
|||||||
+1
@@ -80,6 +80,7 @@ class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
|
|||||||
|
|
||||||
private fun installBridge(project: Project) {
|
private fun installBridge(project: Project) {
|
||||||
installResourcePackaging(project)
|
installResourcePackaging(project)
|
||||||
|
installNativeApplicationDsl(project)
|
||||||
val version = project.providers.gradleProperty(versionProperty).orNull ?: pluginVersion
|
val version = project.providers.gradleProperty(versionProperty).orNull ?: pluginVersion
|
||||||
project.configurations.configureEach { configuration ->
|
project.configurations.configureEach { configuration ->
|
||||||
if (nativeTargetTokens.any { configuration.name.contains(it, ignoreCase = true) }) {
|
if (nativeTargetTokens.any { configuration.name.contains(it, ignoreCase = true) }) {
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.bitsycore.compose.sdl.gradle
|
||||||
|
|
||||||
|
import org.gradle.api.Action
|
||||||
|
import org.gradle.api.NamedDomainObjectCollection
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.plugins.ExtensionAware
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: compose.desktop.native { } — the native counterpart of application { }
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native desktop analog of `compose.desktop { application { mainClass } }`:
|
||||||
|
*
|
||||||
|
* ```kotlin
|
||||||
|
* compose.desktop {
|
||||||
|
* application { mainClass = "bubblewrap.MainJvmKt" } // jvm (upstream)
|
||||||
|
* native { entryPoint = "bubblewrap.main" } // this port
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Declares an executable with the given entry point on every Kotlin/Native
|
||||||
|
* DESKTOP target (mingwX64 / linuxX64 / linuxArm64 / macosArm64) — replacing
|
||||||
|
* the hand-written `targets.withType<KotlinNativeTarget> { binaries.executable
|
||||||
|
* { entryPoint = … } }` block. Targets that already declare an executable are
|
||||||
|
* left untouched, so manual configuration (extra linker flags, custom build
|
||||||
|
* types) still wins.
|
||||||
|
*/
|
||||||
|
abstract class ComposeDesktopNativeExtension {
|
||||||
|
/** Entry point (`package.functionName`) for the native executables. */
|
||||||
|
var entryPoint: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grafts the `native` extension onto the Compose plugin's `desktop` extension
|
||||||
|
(extensions are ExtensionAware, so this needs no compile-time dependency)
|
||||||
|
and materialises the executables in afterEvaluate. All KGP access is
|
||||||
|
reflective: applied from settings, this plugin's classloader is a PARENT of
|
||||||
|
the project's buildscript loader and cannot see KGP types. */
|
||||||
|
internal fun installNativeApplicationDsl(project: Project) {
|
||||||
|
project.pluginManager.withPlugin("org.jetbrains.compose") {
|
||||||
|
project.pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
|
||||||
|
val composeExt = project.extensions.findByName("compose") as? ExtensionAware ?: return@withPlugin
|
||||||
|
val desktopExt = composeExt.extensions.findByName("desktop") as? ExtensionAware ?: return@withPlugin
|
||||||
|
if (desktopExt.extensions.findByName("native") != null) return@withPlugin
|
||||||
|
val nativeExt = desktopExt.extensions.create("native", ComposeDesktopNativeExtension::class.java)
|
||||||
|
project.afterEvaluate { configureNativeExecutables(it, nativeExt) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val desktopKonanFamilies = setOf("MINGW", "LINUX", "OSX")
|
||||||
|
|
||||||
|
private fun configureNativeExecutables(project: Project, ext: ComposeDesktopNativeExtension) {
|
||||||
|
val entryPoint = ext.entryPoint ?: return
|
||||||
|
val kotlinExt = project.extensions.findByName("kotlin") ?: return
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val targets = kotlinExt.javaClass.getMethod("getTargets").invoke(kotlinExt) as NamedDomainObjectCollection<Any>
|
||||||
|
for (target in targets) {
|
||||||
|
val platformType = target.javaClass.getMethod("getPlatformType").invoke(target)
|
||||||
|
if (platformType.toString() != "native") continue
|
||||||
|
val konanTarget = target.javaClass.getMethod("getKonanTarget").invoke(target)
|
||||||
|
val family = konanTarget.javaClass.getMethod("getFamily").invoke(konanTarget)
|
||||||
|
if ((family as Enum<*>).name !in desktopKonanFamilies) continue
|
||||||
|
val binaries = target.javaClass.getMethod("getBinaries").invoke(target)
|
||||||
|
// Respect manual configuration: skip targets that already have an
|
||||||
|
// executable (matching by the binary class, not the name suffix).
|
||||||
|
val existing = (binaries as Iterable<*>).any { it != null && it.javaClass.simpleName == "Executable" }
|
||||||
|
if (existing) continue
|
||||||
|
val executableMethod = binaries.javaClass.methods.first {
|
||||||
|
it.name == "executable" && it.parameterCount == 1 && Action::class.java.isAssignableFrom(it.parameterTypes[0])
|
||||||
|
}
|
||||||
|
executableMethod.invoke(binaries, Action<Any> { binary ->
|
||||||
|
binary.javaClass.getMethod("setEntryPoint", String::class.java).invoke(binary, entryPoint)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-8
@@ -54,17 +54,24 @@ private val desktopTargets = mapOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun registerDataKresTasks(project: Project) {
|
private fun registerDataKresTasks(project: Project) {
|
||||||
val taskNames = project.tasks.names
|
// The zips are PRE-REGISTERED lazily for every desktop target/variant and
|
||||||
val resPackage = project.resolveResourcePackage()
|
// wired to their link task via matching{}.configureEach: executables may
|
||||||
|
// be declared after this afterEvaluate runs — notably by the bridge's OWN
|
||||||
|
// compose.desktop.native { entryPoint } DSL, which also materialises in
|
||||||
|
// afterEvaluate. (A name pre-scan raced that and silently packaged
|
||||||
|
// nothing — the "data.kres not found" crash; and Gradle forbids
|
||||||
|
// registering tasks from inside another task's configuration callback,
|
||||||
|
// so the zip cannot be created reactively either.) An unrealised
|
||||||
|
// registered task costs nothing: if the target has no executable, the
|
||||||
|
// link task never appears and the zip never runs. All content wiring
|
||||||
|
// happens in the zip's own configuration action, which only executes on
|
||||||
|
// realisation — by then the Compose plugin's prepare tasks and the final
|
||||||
|
// compose.resources config exist.
|
||||||
for ((target, sourceSets) in desktopTargets) {
|
for ((target, sourceSets) in desktopTargets) {
|
||||||
for (variant in listOf("Debug", "Release")) {
|
for (variant in listOf("Debug", "Release")) {
|
||||||
val linkName = "link${variant}Executable$target"
|
val linkName = "link${variant}Executable$target"
|
||||||
if (linkName !in taskNames) continue
|
|
||||||
val zipName = "package${variant}ComposeResources$target"
|
val zipName = "package${variant}ComposeResources$target"
|
||||||
if (zipName in taskNames) continue
|
if (zipName in project.tasks.names) continue
|
||||||
val prepareNames = sourceSets
|
|
||||||
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
|
|
||||||
.filter { it in taskNames }
|
|
||||||
val zipTask = project.tasks.register(zipName, Zip::class.java) { task ->
|
val zipTask = project.tasks.register(zipName, Zip::class.java) { task ->
|
||||||
task.description = "Bundles composeResources into data.kres next to the $target ${variant.lowercase()} executable."
|
task.description = "Bundles composeResources into data.kres next to the $target ${variant.lowercase()} executable."
|
||||||
task.archiveFileName.set("data.kres")
|
task.archiveFileName.set("data.kres")
|
||||||
@@ -77,6 +84,10 @@ private fun registerDataKresTasks(project: Project) {
|
|||||||
// decoders — an entry is one fseek+fread, never inflated.
|
// decoders — an entry is one fseek+fread, never inflated.
|
||||||
task.entryCompression = ZipEntryCompression.STORED
|
task.entryCompression = ZipEntryCompression.STORED
|
||||||
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
val resPackage = project.resolveResourcePackage()
|
||||||
|
val prepareNames = sourceSets
|
||||||
|
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
|
||||||
|
.filter { it in project.tasks.names }
|
||||||
for (prepareName in prepareNames) {
|
for (prepareName in prepareNames) {
|
||||||
val sourceSet = prepareName.removePrefix("prepareComposeResourcesTaskFor")
|
val sourceSet = prepareName.removePrefix("prepareComposeResourcesTaskFor")
|
||||||
.replaceFirstChar { it.lowercase() }
|
.replaceFirstChar { it.lowercase() }
|
||||||
@@ -87,7 +98,7 @@ private fun registerDataKresTasks(project: Project) {
|
|||||||
task.dependsOn(project.tasks.named(prepareName))
|
task.dependsOn(project.tasks.named(prepareName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
project.tasks.named(linkName) { it.dependsOn(zipTask) }
|
project.tasks.matching { it.name == linkName }.configureEach { it.dependsOn(zipTask) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Parity harness
|
||||||
|
|
||||||
|
Renders every `:demo` screen on the **native** (SDL/Skia, Kotlin/Native) stack
|
||||||
|
and on the **JVM** upstream-Compose stack from the *same* commonMain
|
||||||
|
composables, then pixel-diffs them per screen. It's a regression net: a screen
|
||||||
|
whose difference jumps far above its usual level is a port bug (missing
|
||||||
|
content, wrong shape/colour, broken clip). Several of this project's renderer
|
||||||
|
regressions would have surfaced here.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/parity/parity.py # all screens (builds first)
|
||||||
|
python scripts/parity/parity.py Buttons Shapes # a subset
|
||||||
|
python scripts/parity/parity.py --no-build # reuse the last renders
|
||||||
|
python scripts/parity/parity.py --gpu=sdl3 # native renderer (default sdl3)
|
||||||
|
```
|
||||||
|
|
||||||
|
Output → `build/parity/` (gitignored):
|
||||||
|
|
||||||
|
- `<pct>_<Name>_compare.png` — native ∣ jvm ∣ amplified-diff, side by side
|
||||||
|
- `<pct>_<Name>_diff.png` — the amplified difference heatmap alone
|
||||||
|
- `report.txt` — screens ranked by % differing
|
||||||
|
|
||||||
|
The `<pct>` prefix is zero-padded, so a plain file listing (or the report)
|
||||||
|
sorts worst-first.
|
||||||
|
|
||||||
|
## Reading the result
|
||||||
|
|
||||||
|
**Absolute % is not the metric — the ranking is.** The two stacks use
|
||||||
|
different default fonts, so text carries a steady baseline difference (in the
|
||||||
|
heatmap, every text line shows a faint *doubled* ghost from slightly different
|
||||||
|
line metrics). Buttons, cards, shapes, images should align (dark). So:
|
||||||
|
|
||||||
|
- **doubled/ghosted text, dark shapes** → normal font drift.
|
||||||
|
- **a solid bright block, or a shape present on one side only** → a real
|
||||||
|
regression. Open the `_compare.png` to see which stack is wrong.
|
||||||
|
|
||||||
|
Compare a screen's % against its neighbours and its own history: Buttons ~16%,
|
||||||
|
Shapes ~14%, Colors ~4% are the healthy baseline (mostly text). A screen that
|
||||||
|
reads 60% when text-light is the bug.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Windows for the native leg today (mingwX64 exe); the JVM leg is host-neutral.
|
||||||
|
- Pillow (`pip install pillow`).
|
||||||
|
- The JVM leg renders all screens headlessly via `ImageComposeScene` in one
|
||||||
|
process (`:demo:run --args=--screenshot-all=…`); native takes one exe launch
|
||||||
|
per screen.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Parity harness — render every demo screen on the NATIVE (SDL/Skia) stack and on
|
||||||
|
the JVM upstream-Compose stack, then pixel-diff them per screen.
|
||||||
|
|
||||||
|
The two stacks share the exact same commonMain screen composables, so a screen
|
||||||
|
that suddenly diverges from its usual difference level is a PORT REGRESSION
|
||||||
|
(missing content, wrong shape/colour, broken clip). Absolute pixel-perfection
|
||||||
|
is NOT the goal — fonts differ between stacks, so text-heavy screens carry a
|
||||||
|
steady baseline difference. The signal is the RANKING: a screen that jumps from
|
||||||
|
~8% to ~60% different is the bug. Four of the renderer regressions this project
|
||||||
|
hit would have surfaced here.
|
||||||
|
|
||||||
|
Usage (from repo root):
|
||||||
|
python scripts/parity/parity.py # all screens
|
||||||
|
python scripts/parity/parity.py Buttons Shapes # a subset
|
||||||
|
python scripts/parity/parity.py --no-build # reuse existing screenshots
|
||||||
|
|
||||||
|
Outputs to build/parity/ (gitignored):
|
||||||
|
<pct>_<Name>_compare.png native | jvm | amplified-diff, side by side
|
||||||
|
<pct>_<Name>_diff.png the amplified pixel-difference heatmap alone
|
||||||
|
report.txt ranked table
|
||||||
|
The <pct> prefix is zero-padded so a plain file listing sorts worst-first.
|
||||||
|
|
||||||
|
Windows-only for the native leg today (mingwX64 exe). Needs Pillow.
|
||||||
|
Native uses the SDL renderer; pass --gpu to change.
|
||||||
|
"""
|
||||||
|
import subprocess, sys, os, shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image, ImageChops, ImageDraw
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
OUT = REPO / "build" / "parity"
|
||||||
|
NATIVE_EXE = REPO / "demo" / "build" / "bin" / "mingwX64" / "debugExecutable" / "demo.exe"
|
||||||
|
WIDTH, HEIGHT = 1000, 700
|
||||||
|
# Per-channel tolerance: below this a pixel counts as "same" (JPEG-ish noise,
|
||||||
|
# sub-pixel AA, font hinting). Tuned so unrelated screens sit well under it.
|
||||||
|
TOL = 32
|
||||||
|
|
||||||
|
GRADLEW = str(REPO / ("gradlew.bat" if os.name == "nt" else "gradlew"))
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, **kw):
|
||||||
|
print("+ " + " ".join(str(c) for c in cmd))
|
||||||
|
return subprocess.run(cmd, cwd=REPO, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
run([GRADLEW, ":demo:linkDebugExecutableMingwX64", "--console=plain"], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def jvm_shots(dst: Path):
|
||||||
|
run([GRADLEW, ":demo:run", f"--args=--screenshot-all={dst}", "--console=plain"], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def native_shot(name: str, dst: Path, gpu: str):
|
||||||
|
bmp = dst / f"{name}.bmp"
|
||||||
|
run([str(NATIVE_EXE), f"--screen={name}", f"--screenshot={bmp}",
|
||||||
|
f"--gpu={gpu}", f"--width={WIDTH}", f"--height={HEIGHT}"],
|
||||||
|
check=False, timeout=60)
|
||||||
|
if bmp.exists():
|
||||||
|
Image.open(bmp).convert("RGB").save(dst / f"{name}.png")
|
||||||
|
bmp.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def diff_pair(native: Image.Image, jvm: Image.Image):
|
||||||
|
"""Return (percent_differing, amplified_diff_image)."""
|
||||||
|
w = min(native.width, jvm.width)
|
||||||
|
h = min(native.height, jvm.height)
|
||||||
|
a = native.crop((0, 0, w, h)).convert("RGB")
|
||||||
|
b = jvm.crop((0, 0, w, h)).convert("RGB")
|
||||||
|
diff = ImageChops.difference(a, b)
|
||||||
|
# A pixel "differs" if any channel exceeds TOL.
|
||||||
|
gray = diff.convert("L")
|
||||||
|
mask = gray.point(lambda p: 255 if p > TOL else 0)
|
||||||
|
differing = sum(mask.point(lambda p: 1 if p else 0).getdata())
|
||||||
|
pct = 100.0 * differing / (w * h)
|
||||||
|
amplified = diff.point(lambda p: min(255, p * 4))
|
||||||
|
return pct, amplified
|
||||||
|
|
||||||
|
|
||||||
|
def side_by_side(native, jvm, diff, path):
|
||||||
|
w = min(native.width, jvm.width)
|
||||||
|
h = min(native.height, jvm.height)
|
||||||
|
canvas = Image.new("RGB", (w * 3 + 20, h + 20), (16, 16, 16))
|
||||||
|
canvas.paste(native.crop((0, 0, w, h)), (0, 10))
|
||||||
|
canvas.paste(jvm.crop((0, 0, w, h)), (w + 10, 10))
|
||||||
|
canvas.paste(diff, (w * 2 + 20, 10))
|
||||||
|
canvas.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
flags = [a for a in sys.argv[1:] if a.startswith("--")]
|
||||||
|
no_build = "--no-build" in flags
|
||||||
|
gpu = next((f.split("=", 1)[1] for f in flags if f.startswith("--gpu=")), "sdl3")
|
||||||
|
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
jvm_dir = OUT / "_jvm"
|
||||||
|
# Clear last run's pct-prefixed visualizations so stale percentages don't
|
||||||
|
# linger alongside the fresh ones.
|
||||||
|
for old in list(OUT.glob("*_compare.png")) + list(OUT.glob("*_diff.png")):
|
||||||
|
old.unlink()
|
||||||
|
|
||||||
|
if not no_build:
|
||||||
|
build()
|
||||||
|
if jvm_dir.exists():
|
||||||
|
shutil.rmtree(jvm_dir)
|
||||||
|
jvm_dir.mkdir(parents=True)
|
||||||
|
jvm_shots(jvm_dir)
|
||||||
|
|
||||||
|
# Screen set: the JVM run enumerates the full registry → use its PNGs as the
|
||||||
|
# source of truth for names (native takes one exe launch each).
|
||||||
|
names = sorted(p.stem for p in jvm_dir.glob("*.png"))
|
||||||
|
if argv:
|
||||||
|
wanted = {n.lower() for n in argv}
|
||||||
|
names = [n for n in names if n.lower() in wanted]
|
||||||
|
if not names:
|
||||||
|
print("No screens matched.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name in names:
|
||||||
|
jvm_png = jvm_dir / f"{name}.png"
|
||||||
|
if not no_build or not (OUT / f"{name}.native.png").exists():
|
||||||
|
native_shot(name, OUT, gpu)
|
||||||
|
src = OUT / f"{name}.png"
|
||||||
|
if src.exists():
|
||||||
|
src.replace(OUT / f"{name}.native.png")
|
||||||
|
native_png = OUT / f"{name}.native.png"
|
||||||
|
if not native_png.exists():
|
||||||
|
results.append((name, None))
|
||||||
|
continue
|
||||||
|
native = Image.open(native_png).convert("RGB")
|
||||||
|
jvm = Image.open(jvm_png).convert("RGB")
|
||||||
|
pct, amp = diff_pair(native, jvm)
|
||||||
|
# Zero-padded pct prefix → worst-first in any file listing.
|
||||||
|
prefix = f"{pct:06.2f}"
|
||||||
|
amp.save(OUT / f"{prefix}_{name}_diff.png")
|
||||||
|
side_by_side(native, jvm, amp, OUT / f"{prefix}_{name}_compare.png")
|
||||||
|
results.append((name, pct))
|
||||||
|
print(f" {name:28s} {pct:6.2f}% differing")
|
||||||
|
|
||||||
|
results.sort(key=lambda r: (-1 if r[1] is None else r[1]), reverse=True)
|
||||||
|
lines = ["screen %differ", "-" * 40]
|
||||||
|
for name, pct in results:
|
||||||
|
lines.append(f"{name:28s} {'NATIVE FAILED' if pct is None else f'{pct:6.2f}%'}")
|
||||||
|
report = "\n".join(lines)
|
||||||
|
(OUT / "report.txt").write_text(report + "\n", encoding="utf-8")
|
||||||
|
print("\n" + report)
|
||||||
|
print(f"\nDiff images + report in {OUT}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Probe driver
|
||||||
|
|
||||||
|
Launches a native compose-desktop-native app window, sends **window-relative**
|
||||||
|
synthetic input, and captures the client area — for reproducing visual bugs
|
||||||
|
deterministically (this is the packaged form of the rigs that caught the
|
||||||
|
square-on-click and TLS-chain regressions).
|
||||||
|
|
||||||
|
Why window-relative + PrintWindow: input coordinates are FRACTIONS of the
|
||||||
|
window client rect and addressed by process name, so a probe doesn't depend on
|
||||||
|
where the window landed or on it being foreground; capture uses `PrintWindow`,
|
||||||
|
which grabs the window even when occluded or unfocused (a plain screen grab
|
||||||
|
would catch whatever's on top).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# screenshot a demo screen
|
||||||
|
python scripts/probe/probe.py demo --screen=Images --shot images.png
|
||||||
|
|
||||||
|
# press-and-hold a point (fraction of the window) and capture DURING the press
|
||||||
|
python scripts/probe/probe.py demo --screen=Buttons --hold 0.1,0.32 --shot press.png
|
||||||
|
|
||||||
|
# hover then capture
|
||||||
|
python scripts/probe/probe.py demo --screen=Shapes --hover 0.3,0.45 --shot hov.png
|
||||||
|
|
||||||
|
# an app built elsewhere (e.g. the bridge example)
|
||||||
|
python scripts/probe/probe.py shared --exe /path/to/shared.exe --click 0.5,0.5 --shot x.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- `proc` = the window's process name (`demo`, `apidemo`, `shared`).
|
||||||
|
- Actions run in order; `--hold` captures mid-press (async), `--click` /
|
||||||
|
`--hover` complete first. `--shot` captures after `--settle` seconds.
|
||||||
|
- Windows-only (uses Win32 input + PrintWindow). Pillow only needed if you
|
||||||
|
post-process the PNG.
|
||||||
|
|
||||||
|
Pairs with `scripts/parity/` (whole-screen diffing); this is for targeted
|
||||||
|
interaction repro.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
param([string]$proc, [string]$out)
|
||||||
|
# Capture a window's CLIENT area by process name, via PrintWindow — works even
|
||||||
|
# when the window is occluded or not focused (unlike a screen grab). Used by the
|
||||||
|
# probe driver to screenshot native app windows deterministically.
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
Add-Type @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public class Cap {
|
||||||
|
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
|
||||||
|
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
|
||||||
|
public struct RECT { public int L, T, R, B; }
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
$h = (Get-Process $proc -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).MainWindowHandle
|
||||||
|
if (-not $h -or $h -eq [IntPtr]::Zero) { Write-Output "NO WINDOW"; exit 1 }
|
||||||
|
$r = New-Object Cap+RECT
|
||||||
|
[Cap]::GetClientRect($h, [ref]$r) | Out-Null
|
||||||
|
$w = $r.R - $r.L; $ht = $r.B - $r.T
|
||||||
|
if ($w -le 0 -or $ht -le 0) { Write-Output "ZERO SIZE"; exit 1 }
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap $w, $ht
|
||||||
|
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$hdc = $g.GetHdc()
|
||||||
|
# flags=3 : PW_CLIENTONLY | PW_RENDERFULLCONTENT (captures accelerated content)
|
||||||
|
[Cap]::PrintWindow($h, $hdc, 3) | Out-Null
|
||||||
|
$g.ReleaseHdc($hdc)
|
||||||
|
$bmp.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
Write-Output "captured ${w}x${ht}"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
param(
|
||||||
|
[string]$proc, # process name of the target window
|
||||||
|
[string]$action, # click | hold | hover | move
|
||||||
|
[double]$fx, # fractional X within the window client area (0..1)
|
||||||
|
[double]$fy, # fractional Y
|
||||||
|
[int]$holdMs = 80 # press duration for 'hold'
|
||||||
|
)
|
||||||
|
# Window-CLIENT-relative synthetic input against a native app window, addressed
|
||||||
|
# by process name — so probes don't depend on where the window landed or on it
|
||||||
|
# being the foreground window (it's raised to foreground first). Coordinates are
|
||||||
|
# FRACTIONS of the client rect, so they're resolution/DPI independent.
|
||||||
|
Add-Type @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public class In {
|
||||||
|
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
|
||||||
|
[DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr h, ref POINT p);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||||
|
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, UIntPtr e);
|
||||||
|
public struct RECT { public int L, T, R, B; }
|
||||||
|
public struct POINT { public int X, Y; }
|
||||||
|
public const uint DOWN = 2, UP = 4;
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
$h = (Get-Process $proc -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).MainWindowHandle
|
||||||
|
if (-not $h -or $h -eq [IntPtr]::Zero) { Write-Output "NO WINDOW"; exit 1 }
|
||||||
|
$r = New-Object In+RECT
|
||||||
|
[In]::GetClientRect($h, [ref]$r) | Out-Null
|
||||||
|
$p = New-Object In+POINT
|
||||||
|
$p.X = [int](($r.R - $r.L) * $fx); $p.Y = [int](($r.B - $r.T) * $fy)
|
||||||
|
[In]::ClientToScreen($h, [ref]$p) | Out-Null
|
||||||
|
[In]::SetForegroundWindow($h) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 120
|
||||||
|
[In]::SetCursorPos($p.X, $p.Y) | Out-Null
|
||||||
|
switch ($action) {
|
||||||
|
"move" { }
|
||||||
|
"hover" { Start-Sleep -Milliseconds 250 }
|
||||||
|
"click" { Start-Sleep -Milliseconds 120; [In]::mouse_event([In]::DOWN,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 60; [In]::mouse_event([In]::UP,0,0,0,[UIntPtr]::Zero) }
|
||||||
|
"hold" { Start-Sleep -Milliseconds 120; [In]::mouse_event([In]::DOWN,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds $holdMs; [In]::mouse_event([In]::UP,0,0,0,[UIntPtr]::Zero) }
|
||||||
|
}
|
||||||
|
Write-Output "$action at $($p.X),$($p.Y)"
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Drive a native compose-desktop-native app window for manual/automated visual
|
||||||
|
checks: launch it, send window-relative input (click / hover / hold / move),
|
||||||
|
and capture the client area via PrintWindow (works even when occluded).
|
||||||
|
|
||||||
|
Built from the ad-hoc rigs used to reproduce the square-on-click and TLS-chain
|
||||||
|
bugs — window-CLIENT-relative and process-addressed, so it doesn't care where
|
||||||
|
the window lands or whether it's focused.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# boot the demo's Images screen, screenshot after 4s
|
||||||
|
python scripts/probe/probe.py demo --screen=Images --shot=images.png
|
||||||
|
|
||||||
|
# boot bubble-wrap (example repo), press-and-hold a bubble mid-sheet, capture
|
||||||
|
python scripts/probe/probe.py shared --exe <path-to>/shared.exe \\
|
||||||
|
--hold 0.55,0.55 --shot press.png
|
||||||
|
|
||||||
|
# hover then capture
|
||||||
|
python scripts/probe/probe.py demo --screen=Shapes --hover 0.3,0.4 --shot hov.png
|
||||||
|
|
||||||
|
`proc` is the window's process name (demo / apidemo / shared). Native apps
|
||||||
|
built here use the mingwX64 debug exe by default; override with --exe. Actions
|
||||||
|
run in order; --shot captures after them. Windows-only. Needs Pillow only if
|
||||||
|
you pass --crop.
|
||||||
|
"""
|
||||||
|
import argparse, subprocess, sys, time, os, signal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_EXE = {
|
||||||
|
"demo": REPO / "demo/build/bin/mingwX64/debugExecutable/demo.exe",
|
||||||
|
"apidemo": REPO / "apidemo/build/bin/mingwX64/debugExecutable/apidemo.exe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ps(script: str, *args: str):
|
||||||
|
return subprocess.run(
|
||||||
|
["powershell", "-ExecutionPolicy", "Bypass", "-File", str(HERE / script), *args],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def frac(s: str):
|
||||||
|
x, y = s.split(",")
|
||||||
|
return float(x), float(y)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("proc", help="window process name (demo / apidemo / shared)")
|
||||||
|
ap.add_argument("--exe", help="path to the executable (default: mingwX64 debug for demo/apidemo)")
|
||||||
|
ap.add_argument("--screen", help="pass --screen=<Name> to the app (demo)")
|
||||||
|
ap.add_argument("--args", default="", help="extra args passed verbatim to the exe")
|
||||||
|
ap.add_argument("--boot", type=float, default=4.0, help="seconds to wait after launch")
|
||||||
|
ap.add_argument("--click", help="fractional X,Y to click (e.g. 0.5,0.5)")
|
||||||
|
ap.add_argument("--hold", help="fractional X,Y to press-and-hold")
|
||||||
|
ap.add_argument("--hover", help="fractional X,Y to hover")
|
||||||
|
ap.add_argument("--hold-ms", type=int, default=900, help="hold duration")
|
||||||
|
ap.add_argument("--settle", type=float, default=0.3, help="seconds between action and capture")
|
||||||
|
ap.add_argument("--shot", help="capture the window to this PNG after the actions")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
exe = Path(args.exe) if args.exe else DEFAULT_EXE.get(args.proc)
|
||||||
|
if not exe or not exe.exists():
|
||||||
|
print(f"exe not found: {exe} (build it, or pass --exe)", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
cmd = [str(exe)]
|
||||||
|
if args.screen:
|
||||||
|
cmd.append(f"--screen={args.screen}")
|
||||||
|
if args.args:
|
||||||
|
cmd += args.args.split()
|
||||||
|
|
||||||
|
proc = subprocess.Popen(cmd)
|
||||||
|
try:
|
||||||
|
time.sleep(args.boot)
|
||||||
|
if args.hover:
|
||||||
|
fx, fy = frac(args.hover)
|
||||||
|
print(ps("_input.ps1", "-proc", args.proc, "-action", "hover", "-fx", str(fx), "-fy", str(fy)).stdout.strip())
|
||||||
|
if args.click:
|
||||||
|
fx, fy = frac(args.click)
|
||||||
|
print(ps("_input.ps1", "-proc", args.proc, "-action", "click", "-fx", str(fx), "-fy", str(fy)).stdout.strip())
|
||||||
|
if args.hold:
|
||||||
|
fx, fy = frac(args.hold)
|
||||||
|
# hold runs async so we can capture DURING the press
|
||||||
|
p = subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-File", str(HERE / "_input.ps1"),
|
||||||
|
"-proc", args.proc, "-action", "hold", "-fx", str(fx), "-fy", str(fy), "-holdMs", str(args.hold_ms)])
|
||||||
|
time.sleep(args.settle)
|
||||||
|
if args.shot:
|
||||||
|
out = str(Path(args.shot).resolve())
|
||||||
|
r = ps("_capture.ps1", "-proc", args.proc, "-out", out)
|
||||||
|
print(f"{r.stdout.strip()} -> {out}")
|
||||||
|
finally:
|
||||||
|
subprocess.run(["taskkill", "/F", "/IM", exe.name], capture_output=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user