fix: refine avatar accessory occlusion

This commit is contained in:
Наер Михаил Александрович 2026-08-26 11:27:32 +03:00
parent 9114def84e
commit edc7a6004d
11 changed files with 137 additions and 31 deletions

View File

@ -1382,7 +1382,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
return path(convexHull(projectedSamples))
}
const accessoryPath = (pose: AvatarPose, node: BodyNode) => {
const accessoryGeometry = (pose: AvatarPose, node: BodyNode) => {
const key = surfaceCacheKey(node.surface)
let localSamples = accessorySamplesCache.get(key)
if (!localSamples) {
@ -1415,11 +1415,13 @@ const accessoryPath = (pose: AvatarPose, node: BodyNode) => {
(node.surface.type === 'cube' || node.surface.type === 'diamond') &&
node.surface.roundness <= 0
) {
return path(hull)
return { hull, path: path(hull) }
}
return smoothClosedPath(densifyClosedPoints(hull))
return { hull, path: smoothClosedPath(densifyClosedPoints(hull)) }
}
type Point2 = readonly [number, number]
const ACCESSORY_FRONT_CROSSING_RATIO = 0.1
const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
@ -1444,18 +1446,83 @@ const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
)
}
const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
const polygonSignedArea = (points: readonly Point2[]) =>
points.reduce((total, point, index) => {
const next = points[(index + 1) % points.length]
return total + point[0] * next[1] - next[0] * point[1]
}, 0) / 2
const convexPolygonIntersection = (subject: Point2[], clip: Point2[]) => {
let output = subject
const orientation = Math.sign(polygonSignedArea(clip)) || 1
const inside = (point: Point2, start: Point2, end: Point2) =>
orientation *
((end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (point[0] - start[0])) >=
0
const crossing = (from: Point2, to: Point2, start: Point2, end: Point2): Point2 => {
const deltaX = to[0] - from[0]
const deltaY = to[1] - from[1]
const edgeX = end[0] - start[0]
const edgeY = end[1] - start[1]
const denominator = deltaX * edgeY - deltaY * edgeX
const amount =
denominator === 0
? 0
: ((start[0] - from[0]) * edgeY - (start[1] - from[1]) * edgeX) / denominator
return [from[0] + amount * deltaX, from[1] + amount * deltaY]
}
clip.forEach((start, index) => {
const end = clip[(index + 1) % clip.length]
const input = output
output = []
input.forEach((current, currentIndex) => {
const previous = input[(currentIndex + input.length - 1) % input.length]
const currentInside = inside(current, start, end)
const previousInside = inside(previous, start, end)
if (currentInside) {
if (!previousInside) output.push(crossing(previous, current, start, end))
output.push(current)
} else if (previousInside) {
output.push(crossing(previous, current, start, end))
}
})
})
return output
}
const AUTO_ACCESSORY_EYE_OCCLUSION_RATIO = 0.08
const eyeOcclusionRatio = (accessory: Point2[], eye: Point2[]) => {
const eyeArea = Math.abs(polygonSignedArea(eye))
if (eyeArea === 0) return 0
return Math.abs(polygonSignedArea(convexPolygonIntersection(eye, accessory))) / eyeArea
}
const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[], visibleEyes: Point2[][]) => {
const layers = nodes
.map(node => {
const depth = rotateWithQuaternion(pose.orientation, node.position)[2]
const geometry = accessoryGeometry(pose, node)
const stronglyOccludesEye =
node.layer === 'auto' &&
visibleEyes.some(
eye =>
eyeOcclusionRatio(
geometry.hull.map(point => [point[0], point[1]] as Point2),
eye
) >= AUTO_ACCESSORY_EYE_OCCLUSION_RATIO
)
return {
id: node.id,
path: accessoryPath(pose, node),
path: geometry.path,
depth,
front:
node.layer === 'front' ||
(node.layer !== 'back' &&
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
(node.layer === 'auto'
? !stronglyOccludesEye
: node.layer !== 'back' &&
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
}
})
.sort((left, right) => left.depth - right.depth)
@ -1477,7 +1544,12 @@ export const renderAvatar = (
const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
const left = leftSamples.map(sample => sample.point)
const right = rightSamples.map(sample => sample.point)
const accessories = accessoryLayers(pose, options.bodyNodes ?? [])
const leftVisible = leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0
const rightVisible = rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0
const visibleEyes = [leftVisible ? left : null, rightVisible ? right : null]
.filter((eye): eye is Point3[] => eye !== null)
.map(eye => eye.map(point => [point[0], point[1]] as Point2))
const accessories = accessoryLayers(pose, options.bodyNodes ?? [], visibleEyes)
const compositePaths = compositeBackPaths(pose, surface)
return {
backPaths: [...compositePaths, ...accessories.backPaths],
@ -1487,8 +1559,8 @@ export const renderAvatar = (
headPath: headPath(pose, surface),
leftPath: path(left),
rightPath: path(right),
leftVisible: leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
rightVisible: rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
leftVisible,
rightVisible,
wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface),
}
}

View File

@ -85,10 +85,10 @@ describe('OneWorks animal construction', () => {
library.avatars.find(avatar => avatar.id === id)?.body.nodes.map(node => node.layer)
expect(layers('oneworks-cat')).toBeUndefined()
expect(layers('oneworks-dog')).toEqual(['front', 'front'])
expect(layers('oneworks-dog')).toEqual(['auto', 'auto'])
expect(layers('oneworks-bear')).toEqual(['back', 'back'])
expect(layers('oneworks-rabbit')).toEqual(['back', 'back'])
expect(layers('oneworks-bun')).toEqual(['front'])
expect(layers('oneworks-bun')).toEqual(['back'])
})
it('keeps the unfinished piglet hidden and removes a persisted copy', () => {
@ -153,7 +153,7 @@ describe('OneWorks animal construction', () => {
avatar => avatar.id === 'oneworks-dog'
)!
expect(dog.body.nodes.map(node => node.layer)).toEqual(['front', 'front'])
expect(dog.body.nodes.map(node => node.layer)).toEqual(['auto', 'auto'])
expect(dog.body.nodes.map(node => node.position)).toEqual([
[-82, -65, 0],
[82, -65, 0],

View File

@ -746,7 +746,7 @@ const oneWorksBundledAvatars: StudioAvatar[] = [
nodes: [
oneWorksNode('bun-crown', 'Rounded bun crown', 'cone', [0, -46, -14], 0.5, 0.23, {
scaleZ: 0.5,
layer: 'front',
layer: 'back',
surface: { roundness: 0.92, topScale: 0.82 },
}),
],

View File

@ -32,6 +32,7 @@ import {
avatarBodyOutlineWidth,
resolveAvatarNodeColor,
useAvatarBodyColorOverride,
useRenderedSceneNodeOrder,
} from '@/features/rendering/avatarAppearance'
import {
LivePixelAvatarCanvas,
@ -199,6 +200,7 @@ export function LiveExpressionPreview({
expressionBodyColor?: string
id: string
}) {
useRenderedSceneNodeOrder(scene)
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, baseBodyColor)
if (renderStyle.type === 'pixel') {

File diff suppressed because one or more lines are too long

View File

@ -39,6 +39,7 @@ describe('rendered avatar scene', () => {
bodyNodes: [node],
})
const scene = createRenderedScene(first)
const initialNodeOrderVersion = scene.nodeOrderVersion.get()
const rotated = renderAvatar(
poseFromExpression({ ...defaultExpression, headY: 35 }),
surfacePresets.sphere,
@ -51,6 +52,11 @@ describe('rendered avatar scene', () => {
expect(findBodyNodePath(scene, 'primary')).toBe(scene.headPath)
expect(findBodyNodePath(scene, node.id)).not.toBeNull()
expect(scene.headPath.get()).toBe(rotated.headPath)
expect(scene.nodeOrderVersion.get()).toBeGreaterThan(initialNodeOrderVersion)
const stableNodeOrderVersion = scene.nodeOrderVersion.get()
paintRenderedScene(scene, rotated)
expect(scene.nodeOrderVersion.get()).toBe(stableNodeOrderVersion)
})
it('updates animated colors without replacing their motion values', () => {
@ -98,13 +104,11 @@ describe('rendered avatar scene', () => {
expect(forcedFront.frontNodeIds).toContain(avatar.body.nodes[0].id)
})
it('moves dog ears between front and back with head depth', () => {
it('moves a dog ear behind the head only when it strongly overlaps an eye', () => {
const studio = loadStudioDocument({ getItem: () => null })
const avatar = studio.library.avatars.find(item => item.id === 'oneworks-dog')!
const detachedEars: string[] = []
let splitDepthExpressions = 0
studio.expressions.forEach(expression => {
const geometry = renderAvatar(
poseFromExpression(expression as Expression),
@ -115,12 +119,6 @@ describe('rendered avatar scene', () => {
const head = pathBounds(geometry.headPath)
const earIds = ['dog-ear-left', 'dog-ear-right'] as const
if (
geometry.frontNodeIds.some(id => earIds.includes(id as (typeof earIds)[number])) &&
geometry.backNodeIds.some(id => earIds.includes(id as (typeof earIds)[number]))
) {
splitDepthExpressions += 1
}
earIds.forEach(nodeId => {
const frontIndex = geometry.frontNodeIds.indexOf(nodeId)
const backIndex = geometry.backNodeIds.indexOf(nodeId)
@ -137,8 +135,25 @@ describe('rendered avatar scene', () => {
})
})
expect(splitDepthExpressions).toBeGreaterThan(0)
expect(detachedEars, 'detached dog ears').toEqual([])
const expressionById = new Map(
studio.expressions.map(expression => [expression.id, expression])
)
const geometryFor = (sequenceId: string) => {
const sequence = studio.sequences.find(item => item.id === sequenceId)!
const expression = expressionById.get(sequence.steps[0].expressionId) as Expression
return renderAvatar(poseFromExpression(expression), avatar.body.primary as SurfaceConfig, 1, {
bodyNodes: avatar.body.nodes as BodyNode[],
})
}
;['idle', 'shy', 'proud'].forEach(sequenceId => {
expect(geometryFor(sequenceId).backNodeIds, sequenceId).toContain('dog-ear-right')
})
expect(geometryFor('angry').frontNodeIds).toEqual(
expect.arrayContaining(['dog-ear-left', 'dog-ear-right'])
)
})
it('keeps bear ears attached behind the head across expressions', () => {

View File

@ -1,6 +1,8 @@
import { useMotionValueEvent, type MotionValue } from 'motion/react'
import { useEffect, useState } from 'react'
import type { RenderedScene } from './renderedScene'
export const avatarBodyOutlineWidth = () => 12
const colorsMatch = (left: string, right: string) =>
@ -26,6 +28,14 @@ export const useAvatarBodyColorOverride = (
return overridden
}
export const useRenderedSceneNodeOrder = (scene: RenderedScene) => {
const [, setVersion] = useState(0)
useMotionValueEvent(scene.nodeOrderVersion, 'change', () => {
setVersion(version => version + 1)
})
}
export const resolveAvatarNodeColor = ({
nodeColor,
bodyColor,

View File

@ -44,6 +44,7 @@ import {
} from '@/features/avatar/manipulationSession'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview'
import { useRenderedSceneNodeOrder } from '@/features/rendering/avatarAppearance'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { type RenderedRotationGizmo } from '@/features/rendering/renderedRotationGizmo'
import {
@ -550,6 +551,7 @@ export function AvatarCanvas({
onManipulationStart: () => Expression
}) {
const { t } = useStudioLanguage()
useRenderedSceneNodeOrder(scene)
const {
wirePaths,
backPaths,

View File

@ -10,6 +10,7 @@ export type RenderedScene = {
frontPaths: MotionValue<string>[]
backNodeIds: { current: (string | null)[] }
frontNodeIds: { current: (string | null)[] }
nodeOrderVersion: MotionValue<number>
leftPath: MotionValue<string>
rightPath: MotionValue<string>
leftOpacity: MotionValue<number>
@ -36,6 +37,7 @@ export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene =>
),
backNodeIds: { current: geometry.backNodeIds },
frontNodeIds: { current: geometry.frontNodeIds },
nodeOrderVersion: motionValue(0),
leftPath: motionValue(geometry.leftPath),
rightPath: motionValue(geometry.rightPath),
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
@ -62,8 +64,14 @@ export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y
export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => {
scene.headPath.set(geometry.headPath)
const nodeOrderChanged =
scene.backNodeIds.current.length !== geometry.backNodeIds.length ||
scene.frontNodeIds.current.length !== geometry.frontNodeIds.length ||
scene.backNodeIds.current.some((id, index) => id !== geometry.backNodeIds[index]) ||
scene.frontNodeIds.current.some((id, index) => id !== geometry.frontNodeIds[index])
scene.backNodeIds.current = geometry.backNodeIds
scene.frontNodeIds.current = geometry.frontNodeIds
if (nodeOrderChanged) scene.nodeOrderVersion.set(scene.nodeOrderVersion.get() + 1)
scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? ''))
scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? ''))
scene.leftPath.set(geometry.leftPath)

View File

@ -15,6 +15,7 @@ import { defaultExpression } from '@/features/avatar/presets'
import {
avatarBodyOutlineWidth,
useAvatarBodyColorOverride,
useRenderedSceneNodeOrder,
} from '@/features/rendering/avatarAppearance'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
@ -31,6 +32,7 @@ function LiveAvatarPreview({
scene: RenderedScene
expressionBodyColor?: string
}) {
useRenderedSceneNodeOrder(scene)
const outlineWidth = avatarBodyOutlineWidth()
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
const fixedSkinColors =

View File

@ -1573,12 +1573,7 @@ export function useStudioController() {
activeSequence && playbackVisual.position !== null
? expressionById.get(activeSequence.steps[playbackVisual.position]?.expressionId ?? '')
: null
const activeSequenceUsesBodyColor =
activeSequence?.steps.some(step => expressionById.get(step.expressionId)?.bodyColor) ?? false
const renderedExpressionBodyColor =
playbackExpression?.bodyColor ??
expression.bodyColor ??
(activeSequenceUsesBodyColor ? renderedColors.body.get() : undefined)
const renderedExpressionBodyColor = playbackExpression?.bodyColor ?? expression.bodyColor
const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
t(
issue === 'missing_semantic_key'