perf(qt): one repaint pulse for the whole shell (GPU 95% -> 25%)
Qt Quick has no partial redraws: any dirty item presents the WHOLE window, and on the owner's hybrid laptop each full-window present costs ~1.2% GPU flat (KWin composite + dGPU scanout; area-independent, while the app's own render is 0.08 ms/frame). The bill was presents/s, and the shell ran unsynchronised ~30 Hz clocks plus two leaks: - QbzShell.pulseMs: ONE Rust-driven 33 ms edge that every continuous animator ticks on (knob QBZ_PULSE_MS). VizSettle now stashes on publish and applies on the pulse (its 16 ms interpolation timer is gone); AmbientField and ImmersiveAtmosphere ride the same edge. - VizSettle writes NOTHING while not live: the always-mounted but invisible immersive panels were dirtying 28 bar rects per FFT publish (named via QT_LOGGING_RULES=qt.quick.dirty=true). - AmbientField renders inline again: ShaderEffectSource scheduleUpdate() double-presents (minimal repro fbo_repro.qml in the investigation folder). - Background mode 2 (ImmersiveAtmosphere) composites its four drifting layers + base + dim + scrim in ONE opaque ShaderEffect (atmosphere.frag, baked by build.rs); numeric parity vs the Image stack verified (atmo-shader-parity-check.py, delta 0.0). The four-Image stack stays as the software/failure fallback. Measured on the owner's machine: 93-97% -> 25-26% GPU, ~34 W -> 12-13 W, ~33 presents/s in both background modes. Full story: qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/GPU-COST-INVESTIGATION.md km
This commit is contained in:
9 files changed
+814
-259
No files matched your search
@@ -1,36 +1,61 @@
|
||||
// AmbientField — the app-wide dynamic background ("modo Cider", phase 14).
|
||||
// AmbientField — the app-wide dynamic background (phase 14), mode 1.
|
||||
//
|
||||
// The QML rendering of the Slint ambient WGSL scene (crates/qbz-ui/ui/
|
||||
// shaders/ambient.wgsl): FOUR album-colored blobs on the shader's EXACT
|
||||
// wandering orbits (10-18s, t scaled 0.75), additive-blended over a black
|
||||
// base so near blobs fuse like the metaball field, plus the shader's
|
||||
// vertical edge shade. Colors come from the ambient_qt album triad
|
||||
// (QbzShell.ambientPrimary/Secondary/Accent, recomputed on track change —
|
||||
// the field crossfades because Canvas repaints are continuous, ~0.6s of
|
||||
// visible blend per the blob softness).
|
||||
// The Qt rendering of the Slint ambient scene (crates/qbz-ui/ui/shaders/
|
||||
// ambient.wgsl, wgpu scene 7). Two arms:
|
||||
//
|
||||
// POC-NOTEs (vs the WGSL original, ambient_qt.rs has the full list):
|
||||
// - No fbm domain warp and no r²/d² metaball potential — plain radial
|
||||
// gradients with 'lighter' compositing approximate the fusion.
|
||||
// - No audio breathe (the POC has no FFT tap; level_smooth = 0 → the
|
||||
// shader's breathe is a constant 1.0 there anyway) and no grain dither.
|
||||
// - Rendered with QPainter (renderTarget Image) so it works on the
|
||||
// software/offscreen path too; 30fps cap + paused when the window is not
|
||||
// being shown (the spec's cost controls §6).
|
||||
// GPU — a ShaderEffect over assets/shaders/ambient.frag rendered into a
|
||||
// ShaderEffectSource at 30 fps (see "THE SHADER IS RENDERED TO A
|
||||
// TEXTURE" below — that schedule is load-bearing, not an optimisation
|
||||
// detail), which is a line-for-line GLSL port of the WGSL:
|
||||
// two-octave fbm domain warp,
|
||||
// four album-coloured METABALLS (r^2/d^2, so the lobes fuse and
|
||||
// stretch), the wide `smoothstep(0.45, 3.4)` iso-surface, the
|
||||
// `mix(0.42, 1.12)` bright/dark spread, the 1.28 saturation push,
|
||||
// the quadratic vertical shade, the 0.92 master and the grain.
|
||||
// CPU — a Canvas fallback for the software scene graph (the Settings
|
||||
// "Software" renderer, QT_QUICK_BACKEND=software, offscreen/VNC),
|
||||
// detected via GraphicsInfo exactly like theme/RoundedImage.qml.
|
||||
//
|
||||
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost
|
||||
// focus. A tiling/mosaic desktop keeps windows visible and unfocused all the
|
||||
// time — pausing on focus loss would stop the field while the user is
|
||||
// looking straight at it. Minimized/hidden is the real "nobody can see it".
|
||||
// WHAT CHANGED AND WHY (2026-08-11). The Canvas used to be the ONLY arm, and
|
||||
// it painted its blobs over a BLACK base, so every pixel outside a blob's
|
||||
// radius was #000000. That is not a small fidelity gap: the reference's colour
|
||||
// is the metaball-WEIGHTED album colour, which is defined everywhere, scaled by
|
||||
// at LEAST 0.42 — a full-window wash that brightens into lobes. Measured on the
|
||||
// owner's side-by-side, Qt was (0,0,0) at (1600,600) and (1690,880) where Slint
|
||||
// was (58,51,5) and (29,27,10): a black top-right quadrant against a continuous
|
||||
// olive field. The Canvas arm now paints that 0.42 floor as its base colour and
|
||||
// corrects the radius (see below), so even the software path stops going black.
|
||||
//
|
||||
// THE RADIUS BUG, worth naming: the WGSL works in aspect-corrected uv, where x
|
||||
// spans [0, aspect] and y spans [0, 1] — so `rr = 0.34` is 0.34 of the window
|
||||
// HEIGHT. The Canvas port read it as `0.36 * max(W, H)`, which on a 1700x1400
|
||||
// window is 612 px against the reference's 476: blobs 29% too big, on top of
|
||||
// the black base.
|
||||
//
|
||||
// STILL ABSENT, deliberately: the audio breathe. The reference multiplies the
|
||||
// blob radius by `1 + 0.12 * level_smooth`, and Slint keeps its 30 fps FFT
|
||||
// drain running for mode 1 — but there it has no choice, because that same
|
||||
// drain is what renders the shader texture the background shows. This arm
|
||||
// drives itself off the shared shell pulse below, so the tap would be
|
||||
// switched on app-wide purely for a +-12% radius pulse, which is the wrong
|
||||
// trade in a port measured on idle CPU. At level 0 the reference's `breathe`
|
||||
// is exactly 1.0, so the field's geometry here is the reference's geometry.
|
||||
//
|
||||
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost focus.
|
||||
// A tiling/mosaic desktop keeps windows visible and unfocused all the time —
|
||||
// pausing on focus loss would stop the field while the user is looking straight
|
||||
// at it. Minimized/hidden is the real "nobody can see it".
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Canvas {
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// The album triad (hex strings from ambient_qt).
|
||||
// The album triad (hex strings from ambient_qt, recomputed on track
|
||||
// change). The field crossfades on its own: both arms redraw continuously,
|
||||
// so the new colours melt in over the blob softness.
|
||||
property color primary: QbzShell.ambientPrimary
|
||||
property color secondary: QbzShell.ambientSecondary
|
||||
property color accent: QbzShell.ambientAccent
|
||||
@@ -44,82 +69,230 @@ Canvas {
|
||||
&& root.Window.window.visibility !== Window.Hidden)
|
||||
: true
|
||||
|
||||
renderTarget: Canvas.Image
|
||||
// See RoundedImage.qml. This one repaints 30x/s for the whole window
|
||||
// lifetime, so it is the canvas most likely to be mid-raster when another
|
||||
// is torn down on the same render thread.
|
||||
renderStrategy: Canvas.Immediate
|
||||
// Same detection as SpectrumBand/RoundedImage: the software and null scene
|
||||
// graphs draw NOTHING for a ShaderEffect, so they take the Canvas arm.
|
||||
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|
||||
|| GraphicsInfo.api === GraphicsInfo.Null
|
||||
// A shader that fails to LOAD must not blank the background either (the
|
||||
// failure SpectrumBand hit in development), so the status is part of the
|
||||
// predicate, not just the backend.
|
||||
readonly property bool fxOk: !root._noShaders && fx.status !== ShaderEffect.Error
|
||||
|
||||
// The shader clock (t = time * 0.75). Accumulated at ~30fps.
|
||||
// --- Render size (shader_underlay.rs:32-38) ------------------------------
|
||||
// The reference renders into an offscreen target that TRACKS the window's
|
||||
// physical pixel size (capped at 2560x1440) and shows it with
|
||||
// `image-fit: fill`. Rendering INLINE now (see the GPU arm below), the
|
||||
// target IS the framebuffer, so the size is simply the item's physical
|
||||
// size — below the 2560x1440 cap this is pixel-for-pixel what the
|
||||
// reference renders at this window size anyway.
|
||||
readonly property real dpr: root.Screen.devicePixelRatio
|
||||
readonly property real resW: Math.max(1, Math.round(width * dpr))
|
||||
readonly property real resH: Math.max(1, Math.round(height * dpr))
|
||||
|
||||
// The scene clock, in seconds. Accumulated at ~30 fps — the spec's cost
|
||||
// control (§6): the field is a background that must never cost more than
|
||||
// the data it shows, and the orbits are 10-18 s long, so 30 fps is already
|
||||
// far more than the motion needs. The seed keeps two launches from opening
|
||||
// on the same pose.
|
||||
property real t: Math.random() * 40.0
|
||||
|
||||
Timer {
|
||||
interval: 33
|
||||
repeat: true
|
||||
running: root.running && root.visible && root.windowShowing
|
||||
onTriggered: {
|
||||
root.t += interval / 1000.0 * 0.75
|
||||
root.requestPaint()
|
||||
// --- ONE PAINT SOURCE, ON THE SHARED PULSE -----------------------------
|
||||
//
|
||||
// Qt Quick has no dirty-region rendering: ANY dirty node redraws the whole
|
||||
// window, so the shell's GPU cost is linear in how many times per second
|
||||
// something asks for a repaint — measured on the owner's 4070 at half
|
||||
// screen, ~1.2% of GPU per repaint/s (30 Hz -> 36%, ~60 Hz -> 70-82%).
|
||||
// WHAT is redrawn barely matters: halving the field's resolution
|
||||
// (QBZ_BG_SCALE=0.5), caching the pane in a layer (QBZ_PANE_LAYER=1) and
|
||||
// slowing the band's interpolation to 10 Hz (QBZ_VIZ_TICK=100) each moved
|
||||
// the number by nothing or almost nothing.
|
||||
//
|
||||
// 2026-08-13 single-pulse redesign: the field advances on the shell pulse
|
||||
// (QbzShell.pulseMs, shell_bridge.rs) — the SAME edge VizSettle and
|
||||
// ImmersiveAtmosphere tick on — so every continuous animator in the shell
|
||||
// dirties the scene in one event-loop turn and the window presents ONCE
|
||||
// per period for all of them. This replaces both earlier sources at once:
|
||||
// the private 33 ms fallback timer AND the publish-edge advance (which
|
||||
// stopped being "a repaint the window was going to do anyway" the moment
|
||||
// VizSettle's publish path went stash-only — an onBarsChanged advance
|
||||
// would now be a SECOND, unsynchronised 30 Hz clock, the exact regression
|
||||
// the pulse exists to kill).
|
||||
readonly property int tickMs: 33
|
||||
|
||||
function advance() {
|
||||
// The GPU arm needs NOTHING but the clock bump: `fx.time` binds root.t,
|
||||
// so this alone dirties the shader and the next present renders it.
|
||||
root.t += root.tickMs / 1000.0
|
||||
if (!root.fxOk)
|
||||
canvas.requestPaint()
|
||||
}
|
||||
|
||||
// The pulse edge. A handler that writes nothing schedules no frame, so
|
||||
// the gates (host freeze, unmounted, minimized/hidden — the GATING RULE
|
||||
// at the top) live here, not in a Timer's `running`.
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onPulseMsChanged() {
|
||||
if (root.running && root.visible && root.windowShowing)
|
||||
root.advance()
|
||||
}
|
||||
}
|
||||
|
||||
onPrimaryChanged: requestPaint()
|
||||
onSecondaryChanged: requestPaint()
|
||||
onAccentChanged: requestPaint()
|
||||
onWidthChanged: requestPaint()
|
||||
onHeightChanged: requestPaint()
|
||||
// --- GPU arm ------------------------------------------------------------
|
||||
//
|
||||
// THE SHADER RENDERS INLINE, NOT INTO A TEXTURE — 2026-08-13, and the
|
||||
// history matters because this is the THIRD shape of this arm:
|
||||
//
|
||||
// 1. Inline (first cut) pinned an RTX 4070 near 100%: pre-pulse the shell
|
||||
// presented 62-180x/s (VizSettle's 16 ms timer, display-rate
|
||||
// animations) and every present re-ran 4 fbm octaves over the window.
|
||||
// 2. ShaderEffectSource + live:false fixed THAT — the fbm ran only on
|
||||
// scheduleUpdate() and other frames sampled the texture. But a minimal
|
||||
// repro (100 ms timer + scheduleUpdate, QSG_RENDER_TIMING) measures
|
||||
// TWO presents per update: one renders the FBO, then the changed
|
||||
// texture dirties the scene again. Under the single-pulse regime the
|
||||
// FBO's rate-decoupling buys nothing (the window presents at the pulse
|
||||
// rate anyway) and the doubled present is pure cost — presents/s is
|
||||
// THE GPU term on the owner's hybrid stack (~1.2% GPU per
|
||||
// full-window present/s, area-independent, measured 2026-08-13).
|
||||
// 3. Inline again (this cut): ONE present per pulse, fbm once per present
|
||||
// at window resolution. The residual exposure — scroll/hover bursts
|
||||
// re-running the field at interaction rate — is transient by
|
||||
// definition, and the field's fragment cost was measured NOT to be the
|
||||
// dominant term (QBZ_BG_SCALE=0.5 moved the GPU ~5 points).
|
||||
//
|
||||
// The picture is UNCHANGED — same program, same uniforms.
|
||||
ShaderEffect {
|
||||
id: fx
|
||||
anchors.fill: parent
|
||||
visible: root.fxOk
|
||||
// The output is opaque (alpha = qt_Opacity = 1), so there is nothing to
|
||||
// blend against — this is the bottom-most layer in the window.
|
||||
blending: false
|
||||
|
||||
// One blob: radial gradient, center color at `a` alpha fading to
|
||||
// transparent (the shader's wide smoothstep falloff).
|
||||
function blob(ctx, cx, cy, r, c, a) {
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
|
||||
g.addColorStop(0.0, Qt.rgba(c.r, c.g, c.b, a))
|
||||
g.addColorStop(0.55, Qt.rgba(c.r, c.g, c.b, a * 0.45))
|
||||
g.addColorStop(1.0, Qt.rgba(c.r, c.g, c.b, 0.0))
|
||||
ctx.fillStyle = g
|
||||
ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r)
|
||||
// Matched BY NAME against the uniform block in ambient.frag.
|
||||
property color primary: root.primary
|
||||
property color secondary: root.secondary
|
||||
property color accent: root.accent
|
||||
property real time: root.t
|
||||
// The absent audio breathe (see the header). The shader keeps the term
|
||||
// so wiring a level later is a one-line binding, not a shader edit.
|
||||
property real levelSmooth: 0.0
|
||||
// The FRAMEBUFFER's size (inline arm): the aspect correction and the
|
||||
// grain frequency describe the pixels actually being written.
|
||||
property real resX: root.resW
|
||||
property real resY: root.resH
|
||||
// The dark legibility scrim, applied inside the program rather than as
|
||||
// a second full-window Rectangle above it — see the shader.
|
||||
property real dim: QbzShell.ambientDim
|
||||
|
||||
onStatusChanged: {
|
||||
if (status === ShaderEffect.Error)
|
||||
console.warn("AmbientField: shader failed:", log)
|
||||
}
|
||||
|
||||
fragmentShader: "../assets/shaders/ambient.frag.qsb"
|
||||
// Shared default-equivalent vertex stage — Qt 6.11's built-in default
|
||||
// emits no qt_TexCoord0, so the program link fails without an explicit
|
||||
// one (the same file SpectrumBand uses).
|
||||
vertexShader: "../assets/shaders/spectrum.vert.qsb"
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
var W = width
|
||||
var H = height
|
||||
if (W <= 0 || H <= 0) return
|
||||
// --- CPU fallback arm ---------------------------------------------------
|
||||
// Plain radial gradients cannot reproduce the metaball tail, so this is an
|
||||
// approximation by construction — but it is the SAME approximation the
|
||||
// reference makes at its extremes: a base of the album mix at the 0.42
|
||||
// floor, with the lobes added on top. What it drops versus the shader is
|
||||
// the fbm warp (the lobes are circles, not amoebas) and the grain.
|
||||
Canvas {
|
||||
id: canvas
|
||||
anchors.fill: parent
|
||||
visible: !root.fxOk
|
||||
renderTarget: Canvas.Image
|
||||
// See RoundedImage.qml. This one repaints 30x/s for the whole window
|
||||
// lifetime, so it is the canvas most likely to be mid-raster when
|
||||
// another is torn down on the same render thread.
|
||||
renderStrategy: Canvas.Immediate
|
||||
|
||||
// Base: the shader's field->0 color (black).
|
||||
ctx.fillStyle = "#000000"
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
Connections {
|
||||
target: root
|
||||
function onPrimaryChanged() { canvas.requestPaint() }
|
||||
function onSecondaryChanged() { canvas.requestPaint() }
|
||||
function onAccentChanged() { canvas.requestPaint() }
|
||||
}
|
||||
onWidthChanged: requestPaint()
|
||||
onHeightChanged: requestPaint()
|
||||
onVisibleChanged: if (visible) requestPaint()
|
||||
|
||||
var t = root.t
|
||||
// The shader's orbit constants (ambient.wgsl cA..cD, aspect folded
|
||||
// back out — window-fraction centers are the plain expressions).
|
||||
var cA = [0.32 + 0.30 * Math.sin(t * 0.40), 0.42 + 0.30 * Math.cos(t * 0.33)]
|
||||
var cB = [0.66 + 0.30 * Math.sin(t * 0.35 + 2.1), 0.56 + 0.32 * Math.cos(t * 0.29 + 1.3)]
|
||||
var cC = [0.50 + 0.34 * Math.cos(t * 0.31 + 4.0), 0.36 + 0.30 * Math.sin(t * 0.45 + 3.2)]
|
||||
var cD = [0.46 + 0.32 * Math.sin(t * 0.27 + 5.3), 0.64 + 0.28 * Math.cos(t * 0.49 + 0.7)]
|
||||
// One lobe: radial gradient, centre colour at `a` alpha fading to
|
||||
// transparent (the shader's wide smoothstep falloff).
|
||||
function blob(ctx, cx, cy, r, c, a) {
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
|
||||
g.addColorStop(0.0, Qt.rgba(c.r, c.g, c.b, a))
|
||||
g.addColorStop(0.55, Qt.rgba(c.r, c.g, c.b, a * 0.45))
|
||||
g.addColorStop(1.0, Qt.rgba(c.r, c.g, c.b, 0.0))
|
||||
ctx.fillStyle = g
|
||||
ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r)
|
||||
}
|
||||
|
||||
// rr = 0.34 of the field; the shader's brightness lands ~0.92 with
|
||||
// the 0.42-1.12 shape spread — the alpha spread below reproduces
|
||||
// that mid-to-low brightness read under the host's dim scrim.
|
||||
var rr = 0.36 * Math.max(W, H)
|
||||
var c4 = Qt.rgba(
|
||||
(primary.r + accent.r) / 2, (primary.g + accent.g) / 2, (primary.b + accent.b) / 2, 1.0)
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
var W = width
|
||||
var H = height
|
||||
if (W <= 0 || H <= 0)
|
||||
return
|
||||
|
||||
ctx.globalCompositeOperation = "lighter"
|
||||
blob(ctx, cA[0] * W, cA[1] * H, rr, primary, 0.50)
|
||||
blob(ctx, cB[0] * W, cB[1] * H, rr * 0.95, secondary, 0.46)
|
||||
blob(ctx, cC[0] * W, cC[1] * H, rr * 0.88, accent, 0.44)
|
||||
blob(ctx, cD[0] * W, cD[1] * H, rr * 0.82, c4, 0.42)
|
||||
ctx.globalCompositeOperation = "source-over"
|
||||
var p = root.primary
|
||||
var s = root.secondary
|
||||
var a = root.accent
|
||||
var c4 = Qt.rgba((p.r + a.r) / 2, (p.g + a.g) / 2, (p.b + a.b) / 2, 1.0)
|
||||
|
||||
// Vertical shade: 16% darker at the very top/bottom edges (the
|
||||
// shader's vshade — chrome sits on calmer color).
|
||||
var v = ctx.createLinearGradient(0, 0, 0, H)
|
||||
v.addColorStop(0.0, Qt.rgba(0, 0, 0, 0.16))
|
||||
v.addColorStop(0.5, Qt.rgba(0, 0, 0, 0.0))
|
||||
v.addColorStop(1.0, Qt.rgba(0, 0, 0, 0.16))
|
||||
ctx.fillStyle = v
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
// THE FLOOR — the whole point of this rewrite. Where the shader has
|
||||
// no dominant lobe it still paints the weighted album colour scaled
|
||||
// by 0.42 (then the 0.92 master), so the base here is the four-way
|
||||
// mix at that same level. Never black.
|
||||
var f = 0.42 * 0.92
|
||||
ctx.fillStyle = Qt.rgba(
|
||||
(p.r + s.r + a.r + c4.r) / 4 * f,
|
||||
(p.g + s.g + a.g + c4.g) / 4 * f,
|
||||
(p.b + s.b + a.b + c4.b) / 4 * f,
|
||||
1.0)
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// The shader's orbit constants, with the aspect folded back out —
|
||||
// in the WGSL's uv the centres are (expr * aspect, expr) over
|
||||
// x in [0, aspect] and y in [0, 1], so as WINDOW FRACTIONS they are
|
||||
// the plain expressions.
|
||||
var t = root.t * 0.75
|
||||
var cA = [0.32 + 0.30 * Math.sin(t * 0.40), 0.42 + 0.30 * Math.cos(t * 0.33)]
|
||||
var cB = [0.66 + 0.30 * Math.sin(t * 0.35 + 2.1), 0.56 + 0.32 * Math.cos(t * 0.29 + 1.3)]
|
||||
var cC = [0.50 + 0.34 * Math.cos(t * 0.31 + 4.0), 0.36 + 0.30 * Math.sin(t * 0.45 + 3.2)]
|
||||
var cD = [0.46 + 0.32 * Math.sin(t * 0.27 + 5.3), 0.64 + 0.28 * Math.cos(t * 0.49 + 0.7)]
|
||||
|
||||
// rr = 0.34 of the field HEIGHT (see the header): the uv space the
|
||||
// constant lives in has y spanning exactly 1.
|
||||
var rr = 0.34 * H
|
||||
ctx.globalCompositeOperation = "lighter"
|
||||
blob(ctx, cA[0] * W, cA[1] * H, rr, p, 0.50)
|
||||
blob(ctx, cB[0] * W, cB[1] * H, rr * 0.95, s, 0.46)
|
||||
blob(ctx, cC[0] * W, cC[1] * H, rr * 0.88, a, 0.44)
|
||||
blob(ctx, cD[0] * W, cD[1] * H, rr * 0.82, c4, 0.42)
|
||||
ctx.globalCompositeOperation = "source-over"
|
||||
|
||||
// Vertical shade — the shader's `1 - 0.16 * ((|y-0.5|*2)^2)` is a
|
||||
// QUADRATIC multiply, so the darkening is nearly nothing across the
|
||||
// middle and reaches 16% only at the very edges. The five stops
|
||||
// below sample that curve instead of the straight ramp the previous
|
||||
// version used (which shaded the whole upper and lower thirds).
|
||||
var v = ctx.createLinearGradient(0, 0, 0, H)
|
||||
v.addColorStop(0.00, Qt.rgba(0, 0, 0, 0.16))
|
||||
v.addColorStop(0.25, Qt.rgba(0, 0, 0, 0.04))
|
||||
v.addColorStop(0.50, Qt.rgba(0, 0, 0, 0.0))
|
||||
v.addColorStop(0.75, Qt.rgba(0, 0, 0, 0.04))
|
||||
v.addColorStop(1.00, Qt.rgba(0, 0, 0, 0.16))
|
||||
ctx.fillStyle = v
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user