Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,5 @@ preview URLs. The animation selector can be searched and dragged into a timeline
or mutating the source animation.

See the [Avatar Runtime guide](https://oneworks.cloud/docs/en/usage/avatar-runtime#react).

For an implicit native entity (`scene.entity.parts: []`), the renderer resolves the native preset and its matching species palette through the editor-owned breed registry, including anatomical parts and projected markings. Explicit parts remain authoritative, and explicit decals override native/generated decals with the same ID. Camera, view, and face settings remain controlled by the supplied definition.
2 changes: 2 additions & 0 deletions packages/react/README.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,5 @@ import { AvatarAnimationPicker, AvatarPresetPicker } from '@oneworks/avatar-reac
支持搜索与拖入时间线,但不会持有或改写源动画数据。

完整说明见 [Avatar Runtime 指南](https://oneworks.cloud/docs/usage/avatar-runtime#react)。

原生实体使用 `scene.entity.parts: []` 时,渲染器通过编辑器维护的原生预设与品种注册表解析对应物种和配色,包括真实解剖部件与表面花纹。显式 parts 保持用户定义;显式 decals 覆盖同 ID 的原生或生成花纹。相机、朝向和表情仍由传入 definition 控制。
33 changes: 33 additions & 0 deletions packages/react/__tests__/native-preset.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { createDefaultAvatarDefinition } from '@oneworks/avatar'
import { resolveNativeAvatarPreset } from '../src/native-preset'

describe('native preset projection cache', () => {
it('reuses geometry and markings across cloned expression and pose frames', () => {
const original = createDefaultAvatarDefinition()
const base = { ...original, scene: { ...original.scene, entity: { preset: 'duck' as const, parts: [] },
appearance: { ...original.scene.appearance, paletteId: 'mallard-duck' } } }
const initial = resolveNativeAvatarPreset(base)
for (let frame = 0; frame < 100; frame++) {
const animated = JSON.parse(JSON.stringify(base))
animated.scene.view.yaw = frame / 100
animated.scene.face.height = 20 + frame % 40
expect(resolveNativeAvatarPreset(animated)).toBe(initial)
}
const changed = JSON.parse(JSON.stringify(base))
changed.scene.appearance.paletteId = 'pekin-duck'
expect(resolveNativeAvatarPreset(changed)).not.toBe(initial)
})
it('bounds cache growth without changing an evicted preset appearance', () => {
const original = createDefaultAvatarDefinition()
const definition = { ...original, scene: { ...original.scene, entity: { preset: 'penguin' as const, parts: [] },
appearance: { ...original.scene.appearance, paletteId: 'emperor-penguin' } } }
const first = resolveNativeAvatarPreset(definition)
for (let index = 0; index < 140; index++) {
resolveNativeAvatarPreset({ ...definition, metadata: { generation: { version: 1, seed: `cache-${index}`, fields: [] } } })
}
const renewed = resolveNativeAvatarPreset(definition)
expect(renewed).not.toBe(first)
expect(renewed).toEqual(first)
})
})
39 changes: 39 additions & 0 deletions packages/react/__tests__/projection-stability.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { act, createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'
import { createDefaultAvatarDefinition, DEFAULT_AVATAR_COAT_PATTERN } from '@oneworks/avatar'
const projected = vi.hoisted(() => ({ props: undefined as Record<string, unknown> | undefined }))
vi.mock('../../../src/App', () => ({ default: () => null }))
vi.mock('../../../src/InteractiveAvatar', () => ({ InteractiveAvatar: (props: Record<string, unknown>) => {
projected.props = props
return null
} }))
import { Avatar } from '../src'

it('keeps projected inputs stable across face frames and invalidates actual coat changes', () => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {} })))
const host = document.createElement('div')
const root = createRoot(host)
const original = createDefaultAvatarDefinition()
const base = { ...original, scene: { ...original.scene,
entity: { preset: 'cat' as const, parts: [] },
appearance: { ...original.scene.appearance, paletteId: 'orange-tabby', coatPattern: { ...DEFAULT_AVATAR_COAT_PATTERN, enabled: true } }
} }
try {
act(() => root.render(createElement(Avatar, { definition: base, autoplay: false })))
const before = projected.props!
const frame = JSON.parse(JSON.stringify(base))
frame.scene.face.height = 15
act(() => root.render(createElement(Avatar, { definition: frame, autoplay: false })))
expect(projected.props!.surfaceDecals).toBe(before.surfaceDecals)
expect(projected.props!.entityParts).toBe(before.entityParts)
frame.scene.appearance.coatPattern.enabled = false
act(() => root.render(createElement(Avatar, { definition: JSON.parse(JSON.stringify(frame)), autoplay: false })))
expect(projected.props!.surfaceDecals).not.toBe(before.surfaceDecals)
} finally {
act(() => root.unmount())
vi.unstubAllGlobals()
}
})
73 changes: 72 additions & 1 deletion packages/react/__tests__/rendering.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { InteractiveAvatar } from '../../../src/InteractiveAvatar'
import { resolveAvatarFaceStyle } from '../../../src/avatarGeometry'
import { getAvatarAnimalBreedTemplate, resolveAvatarAnimalBreedTemplate } from '../../../src/avatarSpeciesBreeds'
import { Avatar } from '../src'
import { resolveNativeAvatarPreset } from '../src/native-preset'
import type { AvatarHandle } from '../src'

let host: HTMLDivElement
Expand Down Expand Up @@ -618,6 +619,76 @@ describe('OneWorks Avatar React rendering', () => {
expect(tapered).not.toBe(untapered)
})

it.each([['cat', 'orange-tabby'], ['cat', 'siamese'], ['dog', 'shiba-inu'] ] as const)(
'applies the selected palette and projected coat to an implicit %s preset (%s)',
(preset, paletteId) => {
const definition = createDefaultAvatarDefinition()
const parts = resolveNativeAvatarPreset({ ...definition, scene: { ...definition.scene, entity: { preset, parts: [] }, appearance: { ...definition.scene.appearance, paletteId, coatPattern: { ...DEFAULT_AVATAR_COAT_PATTERN, enabled: true } } } }).parts
const head = parts.find(part => part.face)!
act(() => root.render(createElement(Avatar, {
autoplay: false,
definition: { ...definition, scene: { ...definition.scene,
entity: { preset, parts: [] },
appearance: { ...definition.scene.appearance, paletteId,
coatPattern: { ...DEFAULT_AVATAR_COAT_PATTERN, enabled: true } },
lighting: { ...definition.scene.lighting, enabled: false },
view: { ...definition.scene.view, yaw: .2, pitch: -.1 }
} }
})))
const surface = host.querySelector(`[data-avatar-entity-part="${head.id}"]`)
expect(surface).not.toBeNull()
expect(surface!.querySelector(`[fill="${head.baseColor}"]`)).not.toBeNull()
expect(host.querySelectorAll('[data-avatar-surface-decal^="coat-"]').length).toBeGreaterThan(0)
}
)

it.each([
['duck', 'mallard-duck', 'bill', 'duck-bill-seam'],
['penguin', 'emperor-penguin', 'beak', 'penguin-face-mask'],
['fox', 'red-fox', null, 'fox-cheek-left']
] as const)('restores complete native %s anatomy and markings with lighting off', (preset, paletteId, mouth, marking) => {
const definition = createDefaultAvatarDefinition()
act(() => root.render(createElement(Avatar, { autoplay: false, definition: {
...definition, scene: { ...definition.scene,
entity: { preset, parts: [] },
appearance: { ...definition.scene.appearance, paletteId },
face: { ...definition.scene.face, mouthEnabled: false, noseEnabled: false },
lighting: { ...definition.scene.lighting, enabled: false },
view: { ...definition.scene.view, yaw: .2, pitch: -.1 }
}
} })))
if (mouth) {
expect(host.querySelector(`[data-avatar-entity-part="${mouth}"]`)).not.toBeNull()
expect(host.querySelector(`[data-avatar-surface-decal="${preset}-${mouth}-explicit-color-override"]`)).not.toBeNull()
}
expect(host.querySelector(`[data-avatar-surface-decal="${marking}"]`)).not.toBeNull()
})

it('changing the native bird breed replaces its authored markings and preserves explicit overrides', () => {
const base = createDefaultAvatarDefinition()
const definition = (paletteId: string) => ({ ...base, scene: { ...base.scene,
entity: { preset: 'penguin' as const, parts: [] },
appearance: { ...base.scene.appearance, paletteId }
} })
const emperor = resolveNativeAvatarPreset(definition('emperor-penguin'))
const other = resolveNativeAvatarPreset(definition('little-penguin'))
expect(emperor.decals).not.toEqual(other.decals)
const mask = emperor.decals.find(decal => decal.id === 'penguin-face-mask')!
const overridden = definition('emperor-penguin')
act(() => root.render(createElement(Avatar, { autoplay: false, definition: {
...overridden, scene: { ...overridden.scene, decals: [{ ...mask, color: '#ab12cd' }] }
} })))
const rendered = host.querySelectorAll('[data-avatar-surface-decal="penguin-face-mask"]')
expect(rendered).toHaveLength(1)
expect(rendered[0]!.getAttribute('fill')).toBe('#ab12cd')
const authored = [{ ...emperor.parts[0]!, baseColor: '#123456' }]
const explicit = resolveNativeAvatarPreset({ ...base, scene: { ...base.scene,
entity: { preset: 'penguin', parts: authored }, appearance: { ...base.scene.appearance, paletteId: 'little-penguin' }
} })
expect(explicit.parts).toBe(authored)
expect(explicit.decals).toEqual([])
})

it('derives procedural coat decals from the public definition', () => {
const definition = createDefaultAvatarDefinition()
const badge = {
Expand Down Expand Up @@ -1066,7 +1137,7 @@ describe('OneWorks Avatar React rendering', () => {
}
] as const) {
const palette = applyAvatarPaletteToneJitter(getAvatarPalette(paletteId), 7)
const parts = applyAvatarEntityPalette(createAvatarEntityParts(preset), palette)
const parts = resolveNativeAvatarPreset({ ...definition, scene: { ...definition.scene, entity: { preset, parts: [] }, appearance: { ...definition.scene.appearance, paletteId, coatPattern: { ...DEFAULT_AVATAR_COAT_PATTERN, enabled: true } } } }).parts
const head = parts.find(part => part.face)!
const color = palette.coat!.patch
const render = (yaw: number, pitch: number) => act(() => root.render(createElement(Avatar, {
Expand Down
73 changes: 73 additions & 0 deletions packages/react/src/native-preset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { getAvatarPalette } from '@oneworks/avatar'
import type { AvatarDefinition } from '@oneworks/avatar'
import {
applyAvatarEntityPalette, createAvatarEntityParts, getAvatarEntityPresetScene
} from '../../../src/avatarEntityPresets'
import {
AVATAR_CAT_BREED_TEMPLATES, AVATAR_DOG_BREED_TEMPLATES,
AVATAR_RABBIT_BREED_TEMPLATES, AVATAR_BEAR_BREED_TEMPLATES,
resolveAvatarCatBreedTemplate, resolveAvatarDogBreedTemplate,
resolveAvatarRabbitBreedTemplate, resolveAvatarBearBreedTemplate
} from '../../../src/avatarBreedTemplates'
import { AVATAR_ANIMAL_BREED_TEMPLATES, resolveAvatarAnimalBreedTemplate } from '../../../src/avatarSpeciesBreeds'

/** Materialize the same native anatomy and markings used by the editor. Explicit
* parts remain concrete authoring truth; no breed or material is inferred for them. */
function materializeNativeAvatarPreset(definition: AvatarDefinition) {
const { scene } = definition
if (scene.entity.parts.length > 0) return { parts: scene.entity.parts, decals: [] }
const preset = scene.entity.preset
const paletteId = scene.appearance.paletteId
const seed = definition.metadata?.generation?.seed ?? scene.appearance.coatPattern?.seed ?? `native-${preset}-${paletteId}`
const pattern = scene.appearance.coatPattern
const cat = preset === 'cat' && AVATAR_CAT_BREED_TEMPLATES.find(item => item.fixed.paletteId === paletteId)
const dog = preset === 'dog' && AVATAR_DOG_BREED_TEMPLATES.find(item => item.fixed.paletteId === paletteId)
const rabbit = preset === 'rabbit' && AVATAR_RABBIT_BREED_TEMPLATES.find(item => item.fixed.paletteId === paletteId)
const bear = preset === 'bear' && AVATAR_BEAR_BREED_TEMPLATES.find(item => item.fixed.paletteId === paletteId)
const animal = AVATAR_ANIMAL_BREED_TEMPLATES.find(item => item.species === preset && item.fixed.paletteId === paletteId)
const breed = cat ? resolveAvatarCatBreedTemplate(cat, seed, pattern)
: dog ? resolveAvatarDogBreedTemplate(dog, seed, pattern)
: rabbit ? resolveAvatarRabbitBreedTemplate(rabbit, seed, pattern)
: bear ? resolveAvatarBearBreedTemplate(bear, seed, pattern)
: animal ? resolveAvatarAnimalBreedTemplate(animal, seed, pattern) : undefined
const nativeScene = getAvatarEntityPresetScene(preset)
if (breed) return {
parts: breed.entityParts,
decals: 'surfaceDecals' in breed ? breed.surfaceDecals ?? nativeScene?.surfaceDecals ?? [] : nativeScene?.surfaceDecals ?? []
}
const original = createAvatarEntityParts(preset)
const palette = getAvatarPalette(paletteId)
const colored = applyAvatarEntityPalette(original, palette)
const head = original.find(part => part.face)
return {
parts: colored.map((part, index) => {
const authored = original[index]!
// Unmapped exposed anatomy keeps its native material instead of becoming
// indistinguishable from fur when the embedding disables surface lighting.
return head && authored.baseColor !== head.baseColor && !palette.entityMaterials?.[part.id]
? { ...part, baseColor: authored.baseColor, foregroundColor: authored.foregroundColor,
highlightColor: authored.highlightColor, shadowColor: authored.shadowColor }
: part
}),
decals: nativeScene?.surfaceDecals ?? []
}
}

const nativePresetCache = new Map<string, ReturnType<typeof materializeNativeAvatarPreset>>()
/** Animation frames often clone definitions. Their view/face changes do not
* change native anatomy, so retain stable renderer inputs across those frames. */
export function resolveNativeAvatarPreset(definition: AvatarDefinition) {
if (definition.scene.entity.parts.length > 0) return materializeNativeAvatarPreset(definition)
const key = JSON.stringify([definition.scene.entity.preset, definition.scene.appearance.paletteId,
definition.metadata?.generation?.seed, definition.scene.appearance.coatPattern])
const existing = nativePresetCache.get(key)
if (existing) {
nativePresetCache.delete(key)
nativePresetCache.set(key, existing)
return existing
}
const resolved = materializeNativeAvatarPreset(definition)
nativePresetCache.set(key, resolved)
if (nativePresetCache.size > 128) nativePresetCache.delete(nativePresetCache.keys().next().value!)
return resolved
}
33 changes: 24 additions & 9 deletions packages/react/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {

import { InteractiveAvatar } from '../../../src/InteractiveAvatar'
import type { AvatarViewState } from '../../../src/InteractiveAvatar'
import { resolveNativeAvatarPreset } from './native-preset'
import { resolveAvatarFaceStyle } from '../../../src/avatarGeometry'
import { renderAvatarPngBlob, renderAvatarSvgSource } from '../../../src/savedAvatarPresets'

Expand Down Expand Up @@ -643,30 +644,44 @@ export const Avatar = forwardRef<AvatarHandle, AvatarProps>(function Avatar({
])

const scene = renderFrame.definition.scene
const renderEntityParts = renderFrame.partTransforms == null
const authoredEntityParts = renderFrame.partTransforms == null
? scene.entity.parts
: currentDefinition.scene.entity.parts
const nativePreset = useMemo(
() => resolveNativeAvatarPreset({ ...renderFrame.definition, scene: { ...scene,
entity: { ...scene.entity, parts: authoredEntityParts }
} }),
[authoredEntityParts, scene.entity.preset, scene.appearance.paletteId, scene.appearance.coatPattern, renderFrame.definition.metadata]
)
const renderEntityParts = nativePreset.parts
const palette = useMemo(
() => resolveAvatarPaletteFromEntityParts(
getAvatarPalette(scene.appearance.paletteId),
renderEntityParts
),
[renderEntityParts, scene.appearance.paletteId]
)
const generatedCoatDecals = scene.appearance.coatPattern?.enabled
const coatPattern = scene.appearance.coatPattern
const coatKey = JSON.stringify(coatPattern)
const generatedCoatDecals = useMemo(() => coatPattern?.enabled
? resolveAvatarCoatPatternDecals({
entityParts: renderEntityParts,
entityPreset: scene.entity.preset,
palette,
paletteId: scene.appearance.paletteId,
pattern: scene.appearance.coatPattern
pattern: coatPattern
})
: []
const explicitDecalIds = new Set(scene.decals.map(decal => decal.id))
const surfaceDecals = [
...generatedCoatDecals.filter(decal => !explicitDecalIds.has(decal.id)),
...scene.decals
]
: [], [renderEntityParts, scene.entity.preset, palette, scene.appearance.paletteId, coatKey])
const explicitDecalsKey = JSON.stringify(scene.decals)
const surfaceDecals = useMemo(() => {
const explicitDecalIds = new Set(scene.decals.map(decal => decal.id))
const generatedDecalIds = new Set(generatedCoatDecals.map(decal => decal.id))
return [
...nativePreset.decals.filter(decal => !explicitDecalIds.has(decal.id) && !generatedDecalIds.has(decal.id)),
...generatedCoatDecals.filter(decal => !explicitDecalIds.has(decal.id)),
...scene.decals
]
}, [nativePreset.decals, generatedCoatDecals, explicitDecalsKey])
const resolvedTheme = theme === 'system' ? systemTheme : theme
const frameShadowDirection = scene.camera.frameShadow.direction * Math.PI / 180
const frameShadow = scene.camera.showFrameShadow
Expand Down
Loading