diff --git a/packages/react/README.md b/packages/react/README.md index c6187eb..cbec78e 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -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. diff --git a/packages/react/README.zh-Hans.md b/packages/react/README.zh-Hans.md index 5966399..953dbf5 100644 --- a/packages/react/README.zh-Hans.md +++ b/packages/react/README.zh-Hans.md @@ -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 控制。 diff --git a/packages/react/__tests__/native-preset.spec.ts b/packages/react/__tests__/native-preset.spec.ts new file mode 100644 index 0000000..292cd50 --- /dev/null +++ b/packages/react/__tests__/native-preset.spec.ts @@ -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) + }) +}) diff --git a/packages/react/__tests__/projection-stability.spec.tsx b/packages/react/__tests__/projection-stability.spec.tsx new file mode 100644 index 0000000..e70ee76 --- /dev/null +++ b/packages/react/__tests__/projection-stability.spec.tsx @@ -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 | undefined })) +vi.mock('../../../src/App', () => ({ default: () => null })) +vi.mock('../../../src/InteractiveAvatar', () => ({ InteractiveAvatar: (props: Record) => { + 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() + } +}) diff --git a/packages/react/__tests__/rendering.spec.tsx b/packages/react/__tests__/rendering.spec.tsx index 70bc3bd..007ff13 100644 --- a/packages/react/__tests__/rendering.spec.tsx +++ b/packages/react/__tests__/rendering.spec.tsx @@ -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 @@ -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 = { @@ -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, { diff --git a/packages/react/src/native-preset.ts b/packages/react/src/native-preset.ts new file mode 100644 index 0000000..d09ad01 --- /dev/null +++ b/packages/react/src/native-preset.ts @@ -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>() +/** 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 +} diff --git a/packages/react/src/renderer.tsx b/packages/react/src/renderer.tsx index 8e09ad2..149c8fa 100644 --- a/packages/react/src/renderer.tsx +++ b/packages/react/src/renderer.tsx @@ -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' @@ -643,9 +644,16 @@ export const Avatar = forwardRef(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), @@ -653,20 +661,27 @@ export const Avatar = forwardRef(function Avatar({ ), [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