mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-08 04:07:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad70f342b | ||
|
|
cf597c12e0 | ||
|
|
cbddf8ca0b | ||
|
|
ad738a643e | ||
|
|
8f5c59bd64 | ||
|
|
914bab840d |
@@ -482,12 +482,23 @@ optimize second. See `ROADMAP.md` for the renderer work these support.
|
|||||||
|
|
||||||
### Frame profiler — `CDN_PROFILE=1`
|
### Frame profiler — `CDN_PROFILE=1`
|
||||||
|
|
||||||
Set the env var and run any native app; every ~2 s of rendered frames it prints
|
Set the env var (`CDN_PROFILE=1`, or `=<path>` for a specific file) and run any
|
||||||
avg/max ms per main-loop phase (`events` / `app` pump / `pump` per-window /
|
native app; every ~2 s of rendered frames it writes avg/max ms per main-loop
|
||||||
`render`). Implemented in `ComposeWindow.kt` via SDL performance counters. Use
|
phase (`events` / `app` pump / `pump` per-window / `render`) to a FILE
|
||||||
it to confirm WHERE time goes before touching draw code — e.g. it showed
|
(`cdn_profile.log` by default — works for GUI-subsystem apps like the demo that
|
||||||
`render` is ~32 ms of a 39 ms bubble-wrap frame, i.e. the renderer, not
|
have no console). The line also carries render SUB-phases (`layout` / `draw` /
|
||||||
composition, is the bottleneck.
|
`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/`
|
### Interaction probe — `scripts/probe/`
|
||||||
|
|
||||||
|
|||||||
+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".
|
||||||
@@ -330,12 +330,17 @@ internal object FrameProfiler {
|
|||||||
val vAvg = (fSum[vName] ?: 0.0) / fFrames
|
val vAvg = (fSum[vName] ?: 0.0) / fFrames
|
||||||
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
|
"$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")
|
val vFile = platform.posix.fopen(fPath, "a")
|
||||||
if (vFile != null) {
|
if (vFile != null) {
|
||||||
platform.posix.fputs(vLine, vFile)
|
platform.posix.fputs(vLine, vFile)
|
||||||
platform.posix.fclose(vFile)
|
platform.posix.fclose(vFile)
|
||||||
}
|
}
|
||||||
|
vStats.reset()
|
||||||
fSum.clear(); fMax.clear()
|
fSum.clear(); fMax.clear()
|
||||||
fFrames = 0
|
fFrames = 0
|
||||||
fLastPrintMs = vNowMs
|
fLastPrintMs = vNowMs
|
||||||
@@ -345,6 +350,8 @@ internal object FrameProfiler {
|
|||||||
|
|
||||||
/* 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()
|
||||||
|
|
||||||
@@ -745,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
+19
-17
@@ -259,6 +259,7 @@ internal class Sdl3Canvas(
|
|||||||
fClip = vRegion
|
fClip = vRegion
|
||||||
applyClip()
|
applyClip()
|
||||||
clearRegion(vRegion)
|
clearRegion(vRegion)
|
||||||
|
com.compose.sdl.graphics.DrawStats.maskRealizations++
|
||||||
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound))
|
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound))
|
||||||
// Attribute the realized mask to the save-frame that pushed the
|
// Attribute the realized mask to the save-frame that pushed the
|
||||||
// clip: inner frames entered since must NOT composite it on their
|
// 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
|
// a 2px margin for glyph overhang / AA bleed, instead of paying a
|
||||||
// mask pass per text run.
|
// mask pass per text run.
|
||||||
admitDraw(inX - 2f, inY - 2f, inX + inBoxWidth + 2f, inY + inBoxHeight + 2f)
|
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
|
||||||
@@ -978,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,
|
||||||
@@ -1040,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,
|
||||||
@@ -1115,6 +1116,7 @@ internal class Sdl3Canvas(
|
|||||||
) {
|
) {
|
||||||
realizePendingClips()
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user