[V1] Fixed SplashScreen Rasterization/Pixel-Detection

This commit is contained in:
Youwes09
2026-02-24 19:52:17 -06:00
parent fec0e5d3f6
commit 7d3d76fa6d
3 changed files with 93 additions and 86 deletions
+83 -77
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import logoUrl from "../../assets/moku-icon.svg"; import logoUrl from "../../assets/moku-icon.svg";
import { getCurrentWindow } from "@tauri-apps/api/window";
export type SplashMode = "loading" | "idle"; export type SplashMode = "loading" | "idle";
export const EXIT_MS = 320; export const EXIT_MS = 320;
@@ -194,8 +194,12 @@ function drawFrame(
-sin * dpr, cos * dpr, -sin * dpr, cos * dpr,
c.cx * dpr, cy * dpr, c.cx * dpr, cy * dpr,
); );
const sw = c.w + STAMP_PAD * 2; // Draw stamp at its natural logical size.
const sh = c.h + STAMP_PAD * 2; // The stamp was baked at (logical * dpr) physical pixels.
// setTransform already applied dpr scaling, so drawing at logical size
// means the stamp maps 1:1 to physical pixels — zero resampling, zero blur.
const sw = stamps[i].width / dpr;
const sh = stamps[i].height / dpr;
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh); ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
} }
@@ -260,12 +264,21 @@ function FpsCounter() {
); );
} }
// ── CardCanvas ──────────────────────────────────────────────────────────────── // ── CardCanvas ────────────────────────────────────────────────────────────────
// Uses invoke("get_scale_factor") to get the real OS DPR from winit/Tauri //
// before building any bitmaps. window.devicePixelRatio is unreliable in // Strategy: best of both worlds.
// nix run and flatpak because WebKitGTK may not have received the HiDPI //
// hint from the compositor by the time the JS context initialises. // LAYOUT → logical pixels (window.innerWidth/Height or Tauri innerSize/scale)
// Tauri reads it from the native window handle, which is always correct. // Cards fill the actual window shape correctly at any size.
//
// QUALITY → physical pixels (Tauri innerSize + scaleFactor)
// Canvas buffer = physical pixels, stamps baked at the true OS DPR.
// No WebKitGTK lies, no late compositor hints, always pixel-perfect.
//
// On every resize both are re-derived together so fullscreen, half-split,
// monitor switch — all produce crisp, correctly-proportioned cards.
//
function CardCanvas({ showFps }: { showFps: boolean }) { function CardCanvas({ showFps }: { showFps: boolean }) {
const ref = useRef<HTMLCanvasElement>(null); const ref = useRef<HTMLCanvasElement>(null);
@@ -278,92 +291,85 @@ function CardCanvas({ showFps }: { showFps: boolean }) {
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high"; ctx.imageSmoothingQuality = "high";
let cancelled = false; const win = getCurrentWindow();
async function init() { // ── Live render state ────────────────────────────────────────────────────
// Prefer the Tauri-sourced scale factor; fall back to the JS value // The frame loop only ever reads from `live`. syncSize builds a complete
// when running outside Tauri (e.g. vite dev server in a browser). // replacement object off-thread then swaps it in one atomic assignment —
let dpr = window.devicePixelRatio || 1; // no frame ever sees a half-rebuilt state.
try { interface RenderState {
const tauriDpr = await invoke<number>("get_scale_factor"); cards: ReturnType<typeof buildCards>["cards"];
if (tauriDpr > 0) dpr = tauriDpr; trigs: ReturnType<typeof buildCards>["trigs"];
} catch { stamps: HTMLCanvasElement[];
// Not in Tauri — window.devicePixelRatio is fine for the browser. vignette: HTMLCanvasElement;
CW: number; CH: number; scale: number;
} }
let live: RenderState | null = null;
if (cancelled) return; // Track what we last built so we skip no-op resize events.
let lastLogW = 0, lastLogH = 0, lastScale = 0;
// Debounce: if a new resize arrives while one is in-flight, we only
// want the most recent result. A simple generation counter handles this.
let buildGen = 0;
async function syncSize() {
const gen = ++buildGen;
const [phys, scale] = await Promise.all([
win.innerSize(),
win.scaleFactor(),
]);
// Another resize fired while we were awaiting — our result is stale.
if (gen !== buildGen) return;
const physW = phys.width;
const physH = phys.height;
const logW = physW / scale;
const logH = physH / scale;
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
lastLogW = logW; lastLogH = logH; lastScale = scale;
// Build everything into a local staging object — nothing visible changes yet.
const built = buildCards(logW, logH);
const stamps = built.cards.map(c => buildStamp(c, scale));
const vig = buildVignette(logW, logH, scale);
// One atomic swap — the frame loop immediately sees the complete new state.
// Canvas dimensions are updated here too so they're always in sync with
// the render state that uses them.
canvas!.width = physW;
canvas!.height = physH;
live = {
cards: built.cards, trigs: built.trigs,
stamps, vignette: vig,
CW: physW, CH: physH, scale,
};
console.log( console.log(
"[SplashScreen] DPR resolution:", `[SplashScreen] syncSize: logical ${Math.round(logW)}×${Math.round(logH)}`,
`window.devicePixelRatio=${window.devicePixelRatio}`, `physical ${physW}×${physH} @${scale.toFixed(3)}×`,
`resolved dpr=${dpr}`,
`logical=${window.innerWidth}x${window.innerHeight}`,
`physical=${Math.round(window.innerWidth * dpr)}x${Math.round(window.innerHeight * dpr)}`,
); );
const vw = window.innerWidth;
const vh = window.innerHeight;
const { cards, trigs } = buildCards(vw, vh);
const stamps = cards.map(c => buildStamp(c, dpr));
let vignette = buildVignette(vw, vh, dpr);
let lastLW = vw;
let lastLH = vh;
let lastDpr = dpr;
let curDpr = dpr;
// syncSize is synchronous for the canvas resize, but fires an async
// Tauri call to update curDpr so the next frame uses the right value.
// This handles moving the window between monitors mid-session.
function syncSize() {
if (!canvas) return;
const lw = canvas.offsetWidth || window.innerWidth;
const lh = canvas.offsetHeight || window.innerHeight;
canvas.width = Math.round(lw * curDpr);
canvas.height = Math.round(lh * curDpr);
if (lw !== lastLW || lh !== lastLH || curDpr !== lastDpr) {
vignette = buildVignette(lw, lh, curDpr);
lastLW = lw;
lastLH = lh;
lastDpr = curDpr;
}
// Async DPR refresh for next resize (e.g. monitor switch)
invoke<number>("get_scale_factor")
.then(d => { if (d > 0) curDpr = d; })
.catch(() => { curDpr = window.devicePixelRatio || 1; });
} }
const ro = new ResizeObserver(() => syncSize());
ro.observe(canvas);
syncSize(); syncSize();
const ro = new ResizeObserver(syncSize);
if (canvas) ro.observe(canvas);
let raf = 0, t0 = -1; let raf = 0, t0 = -1;
function frame(now: number) { function frame(now: number) {
raf = requestAnimationFrame(frame);
if (!live) return;
if (t0 < 0) t0 = now; if (t0 < 0) t0 = now;
drawFrame( const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
ctx!, (now - t0) / 1000, drawFrame(ctx!, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
canvas!.width, canvas!.height,
curDpr, cards, trigs, stamps, vignette,
);
raf = requestAnimationFrame(frame);
} }
raf = requestAnimationFrame(frame); raf = requestAnimationFrame(frame);
// Stash cleanup so the synchronous useEffect return can reach it.
(canvas as any).__splashCleanup = () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}
init();
return () => { return () => {
cancelled = true; cancelAnimationFrame(raf);
(canvas as any).__splashCleanup?.(); ro.disconnect();
}; };
}, []); }, []);
+1
View File
@@ -136,6 +136,7 @@ function TextRow({ value, onChange, label, description, placeholder }: {
); );
} }
// ── Tabs ────────────────────────────────────────────────────────────────────── // ── Tabs ──────────────────────────────────────────────────────────────────────
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) { function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {