feat(stage-ui,stage-ui-three): support fade on hover for three

This commit is contained in:
Neko Ayaka
2026-01-08 15:02:52 +08:00
parent 0b09e30b49
commit bd9d5be254
11 changed files with 253 additions and 11 deletions
+2
View File
@@ -59,6 +59,8 @@
"@proj-airi/server-sdk": "workspace:*",
"@proj-airi/stage-layouts": "workspace:^",
"@proj-airi/stage-ui": "workspace:^",
"@proj-airi/stage-ui-live2d": "workspace:^",
"@proj-airi/stage-ui-three": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@stdlib/string-base-kebabcase": "^0.2.2",
"@tresjs/cientos": "^5.2.1",
@@ -3,6 +3,7 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
import { useCanvasPixelIsTransparentAtPoint } from '@proj-airi/stage-ui/composables/canvas-alpha'
@@ -12,7 +13,7 @@ import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { refDebounced, useBroadcastChannel } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted, ref, toRef, watch } from 'vue'
@@ -32,7 +33,7 @@ import { useControlsIslandStore } from '../stores/controls-island'
import { useWindowStore } from '../stores/window'
const controlsIslandRef = ref<InstanceType<typeof ControlsIsland>>()
const widgetStageRef = ref<{ canvasElement: () => HTMLCanvasElement }>()
const widgetStageRef = ref<InstanceType<typeof WidgetStage>>()
const stageCanvas = toRef(() => widgetStageRef.value?.canvasElement())
const componentStateStage = ref<'pending' | 'loading' | 'mounted'>('pending')
@@ -47,9 +48,31 @@ const isOutsideFor250Ms = refDebounced(isOutside, 250)
const { x: relativeMouseX, y: relativeMouseY } = useElectronRelativeMouse()
// 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 })
// Here we use render-target pixel sampling to keep detection aligned with the actual render output.
const isTransparentByPixels = useCanvasPixelIsTransparentAtPoint(
stageCanvas,
relativeMouseX,
relativeMouseY,
{ regionRadius: 25 },
)
const isTransparentByThree = useThreeSceneIsTransparentAtPoint(
widgetStageRef,
relativeMouseX,
relativeMouseY,
{ regionRadius: 25 },
)
const { stageModelRenderer } = storeToRefs(useSettings())
const isTransparent = computed(() => {
if (stageModelRenderer.value === 'vrm')
return isTransparentByThree.value
if (stageModelRenderer.value === 'live2d')
return isTransparentByPixels.value
return true
})
const { isNearAnyBorder: isAroundWindowBorder } = useElectronMouseAroundWindowBorder({ threshold: 30 })
const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250)
+1
View File
@@ -34,6 +34,7 @@
"@tresjs/post-processing": "^3.2.0",
"@vueuse/core": "^14.1.0",
"culori": "^4.0.2",
"es-toolkit": "catalog:",
"pinia": "^3.0.4",
"postprocessing": "^6.38.2",
"three": "^0.182.0",
@@ -9,7 +9,7 @@
import type { VRM } from '@pixiv/three-vrm'
import type { TresContext } from '@tresjs/core'
import type { DirectionalLight, SphericalHarmonics3, Texture, WebGLRenderTarget } from 'three'
import type { DirectionalLight, SphericalHarmonics3, Texture, WebGLRenderer, WebGLRenderTarget } from 'three'
import type { Vec3 } from '../stores/model-store'
@@ -29,9 +29,10 @@ import {
} from 'three'
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
// From stage-ui-three package
import { useRenderTargetRegionAtClientPoint } from '../composables/render-target'
// pinia store
import { useModelStore } from '../stores/model-store'
// From stage-ui-three package
import { OrbitControls } from './Controls'
import { SkyBox } from './Environment'
import { VRMModel } from './Model'
@@ -99,6 +100,12 @@ const controlsRef = shallowRef<InstanceType<typeof OrbitControls>>()
const tresCanvasRef = shallowRef<TresContext>()
const skyBoxEnvRef = ref<InstanceType<typeof SkyBox>>()
const dirLightRef = ref<InstanceType<typeof DirectionalLight>>()
const { readRenderTargetRegionAtClientPoint, disposeRenderTarget } = useRenderTargetRegionAtClientPoint({
getRenderer: () => tresCanvasRef.value?.renderer.instance as WebGLRenderer | undefined,
getScene: () => tresCanvasRef.value?.scene.value,
getCamera: () => camera.value,
getCanvas: () => tresCanvasRef.value?.renderer.instance.domElement,
})
/*
* Pinia store definition
@@ -194,7 +201,9 @@ onMounted(() => {
}
})
onUnmounted(() => {})
onUnmounted(() => {
disposeRenderTarget()
})
const effectProps = {
saturation: 0.3,
@@ -291,6 +300,10 @@ defineExpose({
canvasElement: () => {
return tresCanvasRef.value?.renderer.instance.domElement
},
camera: () => camera.value,
renderer: () => tresCanvasRef.value?.renderer.instance,
scene: () => modelRef.value?.scene,
readRenderTargetRegionAtClientPoint,
})
</script>
@@ -0,0 +1,71 @@
import type { MaybeRefOrGetter, Ref } from 'vue'
import type { RenderTargetRegionRead } from './render-target'
import { toRef } from '@vueuse/core'
import { computed } from 'vue'
interface HitTestTarget {
readRenderTargetRegionAtClientPoint?: (clientX: number, clientY: number, radius: number) => RenderTargetRegionRead | null
}
export function useThreeSceneIsTransparentAtPoint(
scene: MaybeRefOrGetter<HitTestTarget | undefined>,
pointX: MaybeRefOrGetter<number>,
pointY: MaybeRefOrGetter<number>,
optionsOrThreshold: number | { threshold?: number, regionRadius?: number } = 10,
): Ref<boolean> {
const sceneRef = toRef(scene)
const xRef = toRef(pointX)
const yRef = toRef(pointY)
const options = typeof optionsOrThreshold === 'number'
? { threshold: optionsOrThreshold, regionRadius: 0 }
: optionsOrThreshold
const threshold = options?.threshold ?? 10
const radius = Math.max(0, options?.regionRadius ?? 0)
return computed(() => {
const instance = sceneRef.value
if (!instance || !instance.readRenderTargetRegionAtClientPoint)
return true
const result = instance.readRenderTargetRegionAtClientPoint(xRef.value, yRef.value, radius)
if (!result)
return true
const {
data,
readWidth,
readHeight,
startX,
startY,
centerX,
centerY,
scaleX,
scaleY,
} = result
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
})
}
@@ -0,0 +1,112 @@
import type { Camera, Scene, WebGLRenderer } from 'three'
import { clamp } from 'es-toolkit/math'
import { Vector2, WebGLRenderTarget } from 'three'
import { shallowRef } from 'vue'
export interface RenderTargetRegionRead {
data: Uint8Array
readWidth: number
readHeight: number
startX: number
startY: number
centerX: number
centerY: number
scaleX: number
scaleY: number
}
export function useRenderTargetRegionAtClientPoint(context: {
getRenderer: () => WebGLRenderer | undefined
getScene: () => Scene | undefined
getCamera: () => Camera | undefined
getCanvas: () => HTMLCanvasElement | undefined
}) {
const renderTargetRef = shallowRef<WebGLRenderTarget>()
const renderTargetSize = new Vector2()
function ensureRenderTarget(renderer: WebGLRenderer) {
// Match the offscreen target to the current drawing buffer size (DPI + canvas resize).
// Recreate the target when size changes to avoid reading stale or cropped pixels.
renderer.getDrawingBufferSize(renderTargetSize)
const width = Math.max(1, Math.floor(renderTargetSize.x))
const height = Math.max(1, Math.floor(renderTargetSize.y))
if (!renderTargetRef.value || renderTargetRef.value.width !== width || renderTargetRef.value.height !== height) {
// Dispose old target to keep GPU memory in check.
renderTargetRef.value?.dispose()
renderTargetRef.value = new WebGLRenderTarget(width, height, { depthBuffer: false })
}
return renderTargetRef.value
}
function readRenderTargetRegionAtClientPoint(clientX: number, clientY: number, radius: number): RenderTargetRegionRead | null {
const renderer = context.getRenderer()
const scene = context.getScene()
const camera = context.getCamera()
const canvas = context.getCanvas()
if (!renderer || !scene || !camera || !canvas)
return null
const rect = canvas.getBoundingClientRect()
const xIn = clientX - rect.left
const yIn = clientY - rect.top
const inCanvas = xIn >= 0 && yIn >= 0 && xIn < rect.width && yIn < rect.height
if (!inCanvas)
return null
const renderTarget = ensureRenderTarget(renderer)
const scaleX = renderTarget.width / rect.width
const scaleY = renderTarget.height / rect.height
if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY))
return null
const centerX = Math.floor(xIn * scaleX)
const centerY = Math.floor(renderTarget.height - 1 - yIn * scaleY)
const radiusX = Math.ceil(radius * scaleX)
const radiusY = Math.ceil(radius * scaleY)
const startX = clamp(centerX - radiusX, 0, renderTarget.width - 1)
const endX = clamp(centerX + radiusX, 0, renderTarget.width - 1)
const startY = clamp(centerY - radiusY, 0, renderTarget.height - 1)
const endY = clamp(centerY + radiusY, 0, renderTarget.height - 1)
const readWidth = endX - startX + 1
const readHeight = endY - startY + 1
const data = new Uint8Array(readWidth * readHeight * 4)
const prevTarget = renderer.getRenderTarget()
// Render into our offscreen target so we can read pixels from it.
// Preserve the previous target to avoid breaking the main render pipeline.
renderer.setRenderTarget(renderTarget)
renderer.clear()
renderer.render(scene, camera)
renderer.readRenderTargetPixels(renderTarget, startX, startY, readWidth, readHeight, data)
// Restore the original target so downstream renders keep working.
renderer.setRenderTarget(prevTarget)
return {
data,
readWidth,
readHeight,
startX,
startY,
centerX,
centerY,
scaleX,
scaleY,
}
}
function disposeRenderTarget() {
renderTargetRef.value?.dispose()
renderTargetRef.value = undefined
}
return {
readRenderTargetRegionAtClientPoint,
disposeRenderTarget,
}
}
+2
View File
@@ -1,3 +1,5 @@
export { default as ThreeScene } from './components/ThreeScene.vue'
export * from './composables/hit-test'
export * from './composables/render-target'
export { useModelStore } from './stores/model-store'
export * from './utils/vrm-preview'
@@ -339,6 +339,13 @@ function canvasElement() {
return vrmViewerRef.value?.canvasElement()
}
function readRenderTargetRegionAtClientPoint(clientX: number, clientY: number, radius: number) {
if (stageModelRenderer.value !== 'vrm')
return null
return vrmViewerRef.value?.readRenderTargetRegionAtClientPoint?.(clientX, clientY, radius) ?? null
}
onUnmounted(() => {
if (lipSyncLoopId.value) {
cancelAnimationFrame(lipSyncLoopId.value)
@@ -350,6 +357,7 @@ onUnmounted(() => {
defineExpose({
canvasElement,
readRenderTargetRegionAtClientPoint,
})
onPlaybackFinished(() => {
@@ -132,7 +132,7 @@ export function useCanvasPixelAtPoint(
const scaleY = gl.drawingBufferHeight / height.value
const pixelX = Math.floor(xIn * scaleX)
// Flip Y; subtract 1 to avoid top-edge off-by-one
const pixelY = Math.floor(gl.drawingBufferHeight - 1 - yIn * scaleY)
const pixelY = Math.floor(gl.drawingBufferHeight + yIn * scaleY)
const data = new Uint8Array(4)
try {
@@ -141,6 +141,7 @@ export function useCanvasPixelAtPoint(
catch {
return new Uint8Array([0, 0, 0, 0])
}
return data
})
+10 -1
View File
@@ -106,7 +106,7 @@ catalogs:
specifier: ^8.6.0
version: 8.6.0
es-toolkit:
specifier: ^1.43.0
specifier: 1.43.0
version: 1.43.0
hono:
specifier: 4.11.3
@@ -875,6 +875,12 @@ importers:
'@proj-airi/stage-ui':
specifier: workspace:^
version: link:../../packages/stage-ui
'@proj-airi/stage-ui-live2d':
specifier: workspace:^
version: link:../../packages/stage-ui-live2d
'@proj-airi/stage-ui-three':
specifier: workspace:^
version: link:../../packages/stage-ui-three
'@proj-airi/ui':
specifier: workspace:^
version: link:../../packages/ui
@@ -2600,6 +2606,9 @@ importers:
culori:
specifier: ^4.0.2
version: 4.0.2
es-toolkit:
specifier: 'catalog:'
version: 1.43.0
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3))
+1 -1
View File
@@ -56,7 +56,7 @@ catalog:
builder-util-runtime: ^9.3.1
drizzle-valibot: ^0.4.2
embla-carousel-vue: ^8.6.0
es-toolkit: ^1.43.0
es-toolkit: 1.43.0
hono: 4.11.3
injeca: ^0.1.5
is-network-error: ^1.3.0