perf(stage-tamagotchi): added radius region for detecting Fade on Hover, preventing flickering

This commit is contained in:
Neko Ayaka
2025-12-20 03:58:54 +08:00
parent 87eeeb6294
commit 73a778da13
6 changed files with 279 additions and 21 deletions
@@ -45,7 +45,11 @@ const { isOutside: isOutsideWindow } = useElectronMouseInWindow()
const { isOutside } = useElectronMouseInElement(controlsIslandRef)
const isOutsideFor250Ms = refDebounced(isOutside, 250)
const { x: relativeMouseX, y: relativeMouseY } = useElectronRelativeMouse()
const isTransparent = useCanvasPixelIsTransparentAtPoint(stageCanvas, relativeMouseX, relativeMouseY)
// NOTICE: In real-world use cases of Fade on Hover feature, the cursor may move around the edge of the
// model rapidly, causing flickering effects when checking pixel transparency strictly.
// Here we use `regionRadius` to help to detect with look-ahead strategy to relax the pixel accurate
// check / detection on hovered underlying canvas pixel, therefore inaccurate mouse movements won't cause flickering.
const isTransparent = useCanvasPixelIsTransparentAtPoint(stageCanvas, relativeMouseX, relativeMouseY, { regionRadius: 25 })
const { isNearAnyBorder: isAroundWindowBorder } = useElectronMouseAroundWindowBorder({ threshold: 30 })
const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250)
+1
View File
@@ -105,6 +105,7 @@
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"es-toolkit": "catalog:",
"gpuu": "^1.0.6",
"jszip": "^3.10.1",
"localforage": "^1.10.0",
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { isCanvasRegionTransparent } from './canvas-alpha'
interface GlMockOptions {
hotPixel?: { x: number, y: number, alpha?: number }
width?: number
height?: number
}
/**
* Minimal WebGL context mock that fills readPixels output with transparent pixels,
* except for an optional single hot pixel where alpha is set. Coordinates are in
* drawing buffer space, matching readPixels inputs.
*/
function createGlMock(options: GlMockOptions = {}) {
const drawingBufferWidth = options.width ?? 100
const drawingBufferHeight = options.height ?? 100
const hotPixel = options.hotPixel
const gl = {
drawingBufferWidth,
drawingBufferHeight,
readPixels: (
startX: number,
startY: number,
readWidth: number,
readHeight: number,
_format: number,
_type: number,
data: Uint8Array,
) => {
data.fill(0)
if (!hotPixel)
return
const { x, y, alpha = 255 } = hotPixel
const withinX = x >= startX && x < startX + readWidth
const withinY = y >= startY && y < startY + readHeight
if (!withinX || !withinY)
return
const relX = x - startX
const relY = y - startY
const index = (relY * readWidth + relX) * 4 + 3
data[index] = alpha
},
}
return gl as unknown as WebGLRenderingContext
}
describe('isCanvasRegionTransparent', () => {
it('returns true when cursor is outside canvas bounds', () => {
const gl = createGlMock()
const result = isCanvasRegionTransparent({
gl,
clientX: 150,
clientY: 150,
left: 0,
top: 0,
width: 100,
height: 100,
radius: 10,
threshold: 10,
})
expect(result).toBe(true)
})
it('returns false when an opaque pixel is inside the circular region', () => {
const gl = createGlMock({ hotPixel: { x: 50, y: 49, alpha: 255 } })
const result = isCanvasRegionTransparent({
gl,
clientX: 50,
clientY: 50,
left: 0,
top: 0,
width: 100,
height: 100,
radius: 10,
threshold: 10,
})
expect(result).toBe(false)
})
it('ignores opaque pixels outside the circular region but inside read bounds', () => {
const gl = createGlMock({ hotPixel: { x: 80, y: 80, alpha: 255 } })
const result = isCanvasRegionTransparent({
gl,
clientX: 50,
clientY: 50,
left: 0,
top: 0,
width: 100,
height: 100,
radius: 5,
threshold: 10,
})
expect(result).toBe(true)
})
})
@@ -1,8 +1,97 @@
import type { MaybeRefOrGetter, Ref } from 'vue'
import { toRef, unrefElement, useElementBounding } from '@vueuse/core'
import { clamp } from 'es-toolkit/math'
import { computed } from 'vue'
interface CircleHitTestInput {
gl: WebGL2RenderingContext | WebGLRenderingContext
clientX: number
clientY: number
left: number
top: number
width: number
height: number
radius: number
threshold: number
}
export function isCanvasRegionTransparent({
gl,
clientX,
clientY,
left,
top,
width,
height,
radius,
threshold,
}: CircleHitTestInput) {
if (!width || !height)
return true
if (gl.drawingBufferWidth <= 0 || gl.drawingBufferHeight <= 0)
return true
const xIn = clientX - left
const yIn = clientY - top
const inCanvas = xIn >= 0 && yIn >= 0 && xIn < width && yIn < height
if (!inCanvas)
return true
const scaleX = gl.drawingBufferWidth / width
const scaleY = gl.drawingBufferHeight / height
if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY))
return true
// Translate client-space coords into WebGL buffer space (respecting DPI scaling and flipped Y),
// then read a bounding box that fully contains the desired radius circle. Later we re-check
// the circle constraint in CPU land to avoid missing hits at the edges.
const centerX = Math.floor(xIn * scaleX)
const centerY = Math.floor(gl.drawingBufferHeight - 1 - yIn * scaleY)
const radiusX = Math.ceil(radius * scaleX)
const radiusY = Math.ceil(radius * scaleY)
const startX = clamp(centerX - radiusX, 0, gl.drawingBufferWidth - 1)
const endX = clamp(centerX + radiusX, 0, gl.drawingBufferWidth - 1)
const startY = clamp(centerY - radiusY, 0, gl.drawingBufferHeight - 1)
const endY = clamp(centerY + radiusY, 0, gl.drawingBufferHeight - 1)
const readWidth = endX - startX + 1
const readHeight = endY - startY + 1
const data = new Uint8Array(readWidth * readHeight * 4)
try {
gl.readPixels(startX, startY, readWidth, readHeight, gl.RGBA, gl.UNSIGNED_BYTE, data)
}
catch {
return true
}
const radiusSq = radius * radius
for (let y = 0; y < readHeight; y += 1) {
const gy = startY + y
const dy = (gy - centerY) / scaleY
const dySq = dy * dy
for (let x = 0; x < readWidth; x += 1) {
const gx = startX + x
const dx = (gx - centerX) / scaleX
if (dx * dx + dySq > radiusSq)
continue
const index = (y * readWidth + x) * 4
const alpha = data[index + 3]
if (alpha >= threshold)
return false
}
}
return true
}
export function useCanvasPixelAtPoint(
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>,
pointX: MaybeRefOrGetter<number>,
@@ -72,8 +161,44 @@ export function useCanvasPixelIsTransparentAtPoint(
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>,
pointX: MaybeRefOrGetter<number>,
pointY: MaybeRefOrGetter<number>,
threshold = 10,
optionsOrThreshold: number | { threshold?: number, regionRadius?: number } = 10,
): Ref<boolean> {
const { pixel } = useCanvasPixelAtPoint(canvas, pointX, pointY)
return useCanvasPixelIsTransparent(pixel, threshold)
const options = typeof optionsOrThreshold === 'number'
? { threshold: optionsOrThreshold, regionRadius: 0 }
: optionsOrThreshold
const threshold = options?.threshold ?? 10
const radius = Math.max(0, options?.regionRadius ?? 0)
if (radius === 0) {
const { pixel } = useCanvasPixelAtPoint(canvas, pointX, pointY)
return useCanvasPixelIsTransparent(pixel, threshold)
}
const canvasRef = toRef(canvas)
const xRef = toRef(pointX)
const yRef = toRef(pointY)
const { left, top, width, height } = useElementBounding(canvasRef)
return computed(() => {
const el = unrefElement(canvasRef)
if (!el)
return true
const gl = (el.getContext('webgl2') || el.getContext('webgl')) as WebGL2RenderingContext | WebGLRenderingContext | null
if (!gl)
return true
return isCanvasRegionTransparent({
gl,
clientX: xRef.value,
clientY: yRef.value,
left: left.value,
top: top.value,
width: width.value,
height: height.value,
radius,
threshold,
})
})
}
+22 -3
View File
@@ -63,6 +63,9 @@ catalogs:
electron-updater:
specifier: ^6.6.2
version: 6.6.2
es-toolkit:
specifier: ^1.43.0
version: 1.43.0
posthog-js:
specifier: 1.306.1
version: 1.306.1
@@ -1574,6 +1577,9 @@ importers:
dompurify:
specifier: ^3.3.1
version: 3.3.1
es-toolkit:
specifier: 'catalog:'
version: 1.43.0
gpuu:
specifier: ^1.0.6
version: 1.0.6
@@ -1909,7 +1915,7 @@ importers:
version: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-vue-devtools:
specifier: ^8.0.5
version: 8.0.5(@nuxt/kit@4.0.3(magicast@0.5.1))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
version: 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.25(typescript@5.9.3))
@@ -1943,7 +1949,7 @@ importers:
version: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-vue-devtools:
specifier: ^8.0.5
version: 8.0.5(@nuxt/kit@4.0.3(magicast@0.5.1))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
version: 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.25(typescript@5.9.3))
@@ -32908,7 +32914,6 @@ snapshots:
'@nuxt/kit': 3.20.2(magicast@0.3.5)
transitivePeerDependencies:
- supports-color
optional: true
vite-plugin-inspect@11.3.3(@nuxt/kit@3.20.2(magicast@0.3.5))(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
@@ -32987,6 +32992,20 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-devtools@8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-inspect: 11.3.3(@nuxt/kit@3.20.2(magicast@0.3.5))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vite-plugin-vue-inspector: 5.3.2(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@babel/core': 7.28.5
+15 -14
View File
@@ -1,7 +1,3 @@
catalogMode: prefer
shellEmulator: true
packages:
- packages/**
- plugins/**
@@ -11,16 +7,6 @@ packages:
- apps/**
- '!**/dist/**'
overrides:
array-flatten: npm:@nolyfill/array-flatten@^1.0.44
axios: npm:feaxios@^0.0.23
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
isarray: npm:@nolyfill/isarray@^1.0.44
safe-buffer: npm:@nolyfill/safe-buffer@^1.0.44
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44
catalog:
'@guiiai/logg': 1.1.0
'@moeru/eslint-config': 0.1.0-beta.13
@@ -41,9 +27,12 @@ catalog:
'@xsai/tool': ^0.4.0-beta.10
'@xsai/utils-chat': 0.4.0-beta.5
electron-updater: ^6.6.2
es-toolkit: ^1.43.0
posthog-js: 1.306.1
xsschema: ^0.4.0-beta.10
catalogMode: prefer
catalogs:
rolldown-vite:
vite: npm:rolldown-vite@^7.2.11
@@ -67,3 +56,15 @@ onlyBuiltDependencies:
- spawn-sync
- utf-8-validate
- vue-demi
overrides:
array-flatten: npm:@nolyfill/array-flatten@^1.0.44
axios: npm:feaxios@^0.0.23
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
isarray: npm:@nolyfill/isarray@^1.0.44
safe-buffer: npm:@nolyfill/safe-buffer@^1.0.44
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44
shellEmulator: true