Compare commits

..
6 Commits
Author SHA1 Message Date
Bitsy aad70f342b fix(sdl): stop re-rasterizing text at device size — popup/dialog text no longer steps size/weight
Regression from 120abbad's glyph device-scaling: drawNativeText scaled
the font size by the layer affine (inFontSize * scaleY, int-quantized)
and re-rasterized. Under an animated layer scale — every Material
DropdownMenu/AlertDialog enter transition scale-animates (Menu.kt
scaleX/scaleY) — text re-rasterized at each integer size every frame, so
it visibly stepped through sizes and (via SDL3_ttf re-hinting) weights
as the popup grew in. User-reported, seen across dropdowns and dialogs.

Revert to rasterising at LOGICAL font size + 1:1 blit at the
affine-mapped position (the pre-120abbad behaviour): the layer scale
reaches text POSITION but not glyph size, so animated-scale popups are
stable. Keeps 120abbad's separate clip-mask AA/overlap fixes. Trade-off:
statically graphicsLayer-scaled text renders at logical size (a rare,
minor parity gap) — smoothly scaling glyphs needs blit-scaling a
logical rasterisation (noted for a future text-renderer pass).

Verified: demo dropdown opens with clean uniform text; text no longer
scales with the layer, so mid-animation == settled by construction.
2026-07-14 20:19:40 +02:00
Bitsy cf597c12e0 cleanup: delete dead modifier scaffolding + fix demo's fake cacheKey claims
ModifierElements.kt (ClipModifier/ClipNode + GraphicsLayerModifier/
GraphicsLayerNode with the never-implemented cacheKey) was entirely
unreferenced — apps use the upstream clip/graphicsLayer; nothing read
these. Deleted the file (compiles clean across :ui and :demo).

The demo GraphicsLayer screen advertised caching that never existed
('Cached subtree (cacheKey)', 'Combined (cache + rotation)', a
'(cached)' label) over plain graphicsLayer() calls. Removed those two
fabricated sections and the cacheKey wording; the honest Rotation
(live) / Scale / Alpha sections already show what graphicsLayer does.
2026-07-14 20:02:30 +02:00
Bitsy cbddf8ca0b docs: RESOLVED — demo-70/apidemo-144 was mixed-refresh monitors, not a renderer bug
User has a 75Hz main + 144Hz second screen; each app's fps is just the
refresh of the monitor its window opened on (SDL vsync follows the
display). Instrumentation already showed both apps at ~3.7ms draw —
well under both the 13.3ms (75Hz) and 6.7ms (144Hz) budgets, so no perf
problem exists. Records that retained-layers/dirty-region/cacheKey are
speculative with no demonstrated need; only the dead-scaffolding cleanup
is worth doing regardless.
2026-07-14 19:54:04 +02:00
Bitsy ad738a643e docs(CLAUDE): profiler now file-output + draw counters + CDN_FORCERENDER; vsync/display caveat
Documents the DrawStats per-frame counters, file output, and
CDN_FORCERENDER added this session, plus the load-bearing caveat: present
is vsync-bound so all timings are capped by the dev display's refresh —
profile on the target rate before diagnosing a frame-rate gap.
2026-07-14 19:53:12 +02:00
Bitsy 8f5c59bd64 feat(window): per-frame DrawStats in profiler + CDN_FORCERENDER; correct the 70fps theory
Extends CDN_PROFILE with per-frame draw counters (DrawStats: geometry
submits, vertices, mask realizations, text/image blits) and a
CDN_FORCERENDER=1 flag to render every frame for steady-state timing on
idle screens.

Used them to test the demo-70fps theory and DISPROVE it: forced-render,
both apps pin at 75 fps (this dev display's refresh), present ~9ms; the
demo's draw (~3.7ms, geo=40, masks=0) is actually LIGHTER than apidemo's
(~2.9ms, geo=146, masks=74). So the demo is NOT draw-bound — the
70-vs-144 split is specific to the user's 144Hz monitor and can't be
reproduced on a 75Hz box. Recorded the correct next step (profile on the
144Hz machine) in NEXT-SESSION.md; noted the vsync/display confound in
CLAUDE.md's profiler docs. Thread A (spurious continuous render) resolved
as a non-issue — the demo idles when static.
2026-07-14 19:52:24 +02:00
Bitsy 914bab840d docs: handoff notes for the renderer-caching session
Captures the demo-70fps investigation for the next session: (A) the
likely-cheaper continuous-render / hover-self-loop thread to chase first,
and (B) the cacheKey/retained-layer work — dead scaffolding today,
blocked on a content-draw-redirect primitive (the 'renderer rewrite')
that also underpins dirty regions. Lists the tooling + existing
infrastructure to build on.
2026-07-14 19:34:39 +02:00
8 changed files with 199 additions and 169 deletions
+17 -6
View File
@@ -482,12 +482,23 @@ optimize second. See `ROADMAP.md` for the renderer work these support.
### Frame profiler — `CDN_PROFILE=1`
Set the env var and run any native app; every ~2 s of rendered frames it prints
avg/max ms per main-loop phase (`events` / `app` pump / `pump` per-window /
`render`). Implemented in `ComposeWindow.kt` via SDL performance counters. Use
it to confirm WHERE time goes before touching draw code — e.g. it showed
`render` is ~32 ms of a 39 ms bubble-wrap frame, i.e. the renderer, not
composition, is the bottleneck.
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/`
+114
View File
@@ -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".
@@ -330,12 +330,17 @@ internal object FrameProfiler {
val vAvg = (fSum[vName] ?: 0.0) / fFrames
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
}
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + "\n"
// 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
@@ -345,6 +350,8 @@ internal object FrameProfiler {
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
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)
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
@@ -745,7 +752,10 @@ internal class WindowInstance(
// Frame pump
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() {
val vRender = renderBackend ?: return
@@ -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,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"
}
@@ -259,6 +259,7 @@ internal class Sdl3Canvas(
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
@@ -963,6 +964,7 @@ internal class Sdl3Canvas(
// 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()
// Paragraph-level decoration bits forwarded to every wrapped line.
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
@@ -978,26 +980,25 @@ internal class Sdl3Canvas(
// 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,
// which pushed every icon ~0.1 em below centre.
// Device-space scale for GLYPHS: the pen position already maps through
// the affine, but font size and centering boxes must scale with it too
// or text inside a graphicsLayer(scale) renders at full size, off
// centre (JVM/Skia scales glyphs with the canvas). Wrap stays in LOCAL
// units below so line breaks match what layout measured.
val vTextScaleX = sqrt(fMa * fMa + fMb * fMb)
val vTextScaleY = sqrt(fMc * fMc + fMd * fMd)
val vDeviceFontPx =
if (vTextScaleY == 1f) inFontSizePx
else (inFontSizePx * vTextScaleY).toInt().coerceAtLeast(1)
// Text is rasterised at its LOGICAL font size (stable glyph-texture cache
// + stable hinting) and blit 1:1 at the affine-mapped pen position; the
// layer's scale reaches position but NOT glyph size. Re-rasterising at a
// per-frame device size (an earlier attempt) made text under an animated
// layer scale — every Material popup/dropdown enter transition
// (Menu.kt scaleX/scaleY) — step through integer sizes with visible
// size/weight jumps, since SDL3_ttf re-hints at each size. Smoothly
// scaling glyphs would need blit-scaling a logical-size rasterisation
// (JVM/Skia GPU-scales the layer) — a future text-renderer enhancement;
// until then logical-size + no glyph scale is the stable choice.
if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
vTr.drawText(
inText = inText,
inX = mapX(inX, inY).toInt(),
inY = mapY(inX, inY).toInt(),
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
inBoxHeight = (inBoxHeight * vTextScaleY).toInt(),
inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = inBoxHeight.toInt(),
inColor = vColor,
inFontSize = vDeviceFontPx,
inFontSize = inFontSizePx,
inAlign = inTextAlign,
inFontFamily = inFontFamily,
inFontVariations = inFontVariations,
@@ -1040,10 +1041,10 @@ internal class Sdl3Canvas(
// scale with the layer. Rotation still only repositions.
inX = mapX(inX, vLineY).toInt(),
inY = mapY(inX, vLineY).toInt(),
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
inBoxHeight = (vLineH * vTextScaleY).toInt(),
inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = vLineH.toInt(),
inColor = vColor,
inFontSize = vDeviceFontPx,
inFontSize = inFontSizePx,
inAlign = inTextAlign,
inFontFamily = inFontFamily,
inFontVariations = inFontVariations,
@@ -1115,6 +1116,7 @@ internal class Sdl3Canvas(
) {
realizePendingClips()
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
// (an offscreen render just borrowed the render target).
fScope.flush()
@@ -141,6 +141,8 @@ internal class Sdl3DrawScope(
)
}
SDL_RenderGeometry(fRenderer.reinterpret(), null, fBatch, fBatchCount, null, 0)
com.compose.sdl.graphics.DrawStats.geometrySubmits++
com.compose.sdl.graphics.DrawStats.vertices += fBatchCount
fBatchCount = 0
}
@@ -42,55 +42,9 @@ internal fun GraphicsLayerScreen() {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenTitle(
"Modifier.graphicsLayer",
"2D transform (scale / rotation / translation) + alpha + optional cacheKey opt-in. " +
"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.",
"2D transform (scale / rotation / translation) + alpha, applied to a subtree.",
)
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.") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
for (vI in 0..4) {