fix: refine avatar accessory occlusion
This commit is contained in:
parent
9114def84e
commit
edc7a6004d
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 },
|
||||
}),
|
||||
],
|
||||
|
||||
@ -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
@ -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', () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user