diff --git a/src/app/components/common.tsx b/src/app/components/common.tsx
index a6a8dfa..b3381bd 100644
--- a/src/app/components/common.tsx
+++ b/src/app/components/common.tsx
@@ -9,7 +9,9 @@ import { Separator } from '@/components/ui/separator'
import { useStudioLanguage } from '@/i18n'
import { type PlaybackStatus } from '@/app/studio-utils'
+import type { AvatarRenderStyle } from '@/features/avatar/avatars'
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
+import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
export function ControlSection({
title,
@@ -41,12 +43,14 @@ export function SnapshotPreview({
background,
colorFrom,
colorTo,
+ renderStyle,
}: {
scene: RenderedScene
colors: RenderedColors
background: SnapshotBackground
colorFrom: string
colorTo: string
+ renderStyle: AvatarRenderStyle
}) {
const id = useId().replace(/:/g, '')
const clipId = `${id}-clip`
@@ -58,40 +62,60 @@ export function SnapshotPreview({
: background === 'linear'
? `url(#${linearId})`
: `url(#${radialId})`
+ const pixelBackground =
+ background === 'solid'
+ ? colorFrom
+ : background === 'linear'
+ ? `linear-gradient(135deg, ${colorFrom}, ${colorTo})`
+ : background === 'radial'
+ ? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
+ : undefined
return (
-
-
+
+ {renderStyle.type === 'pixel' ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/src/app/styles.css b/src/app/styles.css
index 70fcc7e..814b77b 100644
--- a/src/app/styles.css
+++ b/src/app/styles.css
@@ -323,6 +323,8 @@ input {
}
.avatar {
+ position: relative;
+ z-index: 2;
display: block;
width: 100%;
height: 100%;
@@ -330,6 +332,25 @@ input {
cursor: grab;
touch-action: none;
}
+.avatar-pixel-canvas {
+ position: absolute;
+ z-index: 1;
+ inset: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+ image-rendering: pixelated;
+ pointer-events: none;
+}
+.is-pixel-rendered .avatar-head,
+.is-pixel-rendered .avatar-eye {
+ fill: transparent;
+ transition: none;
+}
+.is-pixel-rendered .avatar-head.cyan-outline,
+.is-pixel-rendered .avatar-eye.cyan-outline {
+ stroke: #51ddff;
+}
.avatar:active {
cursor: grabbing;
}
@@ -344,7 +365,7 @@ input {
background: var(--muted);
}
-.export-avatar-summary > svg {
+.export-avatar-summary > .avatar-preview {
width: 62px;
height: 62px;
flex: none;
@@ -560,7 +581,7 @@ input {
-6px 0;
background-size: 12px 12px;
}
-.snapshot-preview svg {
+.snapshot-preview .avatar-preview {
display: block;
width: 100%;
height: 100%;
@@ -1238,7 +1259,7 @@ p {
background: white;
box-shadow: 0 3px 10px rgb(24 28 35 / 5%);
}
-.state-playback-step svg {
+.state-playback-step .avatar-preview {
width: 100%;
height: 100%;
}
@@ -1370,7 +1391,7 @@ p {
.avatar-shelf .avatar-card:active {
cursor: grabbing;
}
-.avatar-shelf .avatar-card svg {
+.avatar-shelf .avatar-card .avatar-preview {
height: auto;
min-height: 0;
flex: 1 1 auto;
@@ -1433,7 +1454,7 @@ p {
background: #edf1ff;
color: #4168d5;
}
-.mobile-avatar-tab > svg {
+.mobile-avatar-tab > .avatar-preview {
width: 27px;
height: 27px;
margin-block: -4px -1px;
@@ -1604,6 +1625,18 @@ p {
.color-panel {
position: relative;
}
+.render-style-field > [data-slot='select-trigger'] {
+ width: 150px;
+}
+.pixel-render-options {
+ display: grid;
+ margin-top: 14px;
+ padding-top: 4px;
+ border-top: 1px solid #eceef2;
+}
+.pixel-render-options .switch {
+ margin-top: 0;
+}
.inherit-colors {
position: absolute;
top: 14px;
@@ -2066,7 +2099,7 @@ p {
padding: 7px;
cursor: pointer;
}
-.avatar-card svg {
+.avatar-card .avatar-preview {
display: block;
width: 100%;
height: 105px;
@@ -2095,11 +2128,15 @@ p {
width: 22px;
height: 22px;
}
-.expression-card svg {
+.expression-card .avatar-preview {
display: block;
width: 100%;
height: 64px;
}
+.avatar-preview:is(canvas) {
+ image-rendering: pixelated;
+ object-fit: contain;
+}
.expression-card > span {
position: absolute;
right: 5px;
diff --git a/src/features/animation/components/SequenceWorkspace.tsx b/src/features/animation/components/SequenceWorkspace.tsx
index c73762d..14f76bf 100644
--- a/src/features/animation/components/SequenceWorkspace.tsx
+++ b/src/features/animation/components/SequenceWorkspace.tsx
@@ -29,7 +29,11 @@ import {
type AvatarSequence,
type SequenceStep,
} from '@/features/animation/sequences'
-import { type AvatarColors, type AvatarEyeDefaults } from '@/features/avatar/avatars'
+import {
+ type AvatarColors,
+ type AvatarEyeDefaults,
+ type AvatarRenderStyle,
+} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { type Expression } from '@/features/avatar/geometry'
@@ -41,6 +45,7 @@ export function SequenceWorkspace({
bodyNodes,
colors,
avatarEyes,
+ renderStyle,
selectedStepId,
backButtonRef,
reduceMotion,
@@ -64,6 +69,7 @@ export function SequenceWorkspace({
bodyNodes: BodyNode[]
colors: AvatarColors
avatarEyes: AvatarEyeDefaults
+ renderStyle: AvatarRenderStyle
selectedStepId: string | null
backButtonRef: RefObject
reduceMotion: boolean
@@ -252,6 +258,7 @@ export function SequenceWorkspace({
bodyNodes={bodyNodes}
colors={colors}
avatarEyes={avatarEyes}
+ renderStyle={renderStyle}
id={`sequence-${editing.draft.id}-${step.id}`}
/>
{String(expressionIndex).padStart(2, '0')}
@@ -389,6 +396,7 @@ export function SequenceWorkspace({
bodyNodes={bodyNodes}
colors={colors}
avatarEyes={avatarEyes}
+ renderStyle={renderStyle}
id={`sequence-library-${index}`}
/>
{String(index).padStart(2, '0')}
diff --git a/src/features/avatar/__tests__/avatars-test.ts b/src/features/avatar/__tests__/avatars-test.ts
index ded9e96..69835a8 100644
--- a/src/features/avatar/__tests__/avatars-test.ts
+++ b/src/features/avatar/__tests__/avatars-test.ts
@@ -6,6 +6,7 @@ import {
createAvatar,
defaultAvatarEyes,
parseAvatarEyeDefaults,
+ parseAvatarRenderStyle,
resolveAvatarBehavior,
} from '@/features/avatar/avatars'
import { initialExpressions } from '@/features/avatar/presets'
@@ -35,6 +36,28 @@ describe('avatar eye defaults', () => {
})
})
+describe('avatar render style', () => {
+ it('keeps vector rendering as the compatible default', () => {
+ expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
+ })
+
+ it('sanitizes pixel settings', () => {
+ expect(
+ parseAvatarRenderStyle({
+ type: 'pixel',
+ resolution: 500,
+ })
+ ).toEqual({
+ type: 'pixel',
+ resolution: 192,
+ })
+ expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({
+ type: 'pixel',
+ resolution: 8,
+ })
+ })
+})
+
describe('avatar behavior library', () => {
const base = {
expressions: initialExpressions,
diff --git a/src/features/avatar/avatars.ts b/src/features/avatar/avatars.ts
index 03d5b96..72e2375 100644
--- a/src/features/avatar/avatars.ts
+++ b/src/features/avatar/avatars.ts
@@ -20,10 +20,16 @@ export type StudioAvatar = {
body: AvatarBody
colors: AvatarColors
eyes: AvatarEyeDefaults
+ renderStyle: AvatarRenderStyle
behavior?: AvatarBehaviorLibrary
}
export type AvatarColors = { body: string; eyes: string }
+export type PixelRenderStyle = {
+ type: 'pixel'
+ resolution: number
+}
+export type AvatarRenderStyle = { type: 'vector' } | PixelRenderStyle
export type AvatarEyeDefaults = Pick<
Expression,
| 'widthLeft'
@@ -39,6 +45,11 @@ export type AvatarEyeDefaults = Pick<
| 'rightAngle'
>
export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' }
+export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
+export const defaultPixelRenderStyle: PixelRenderStyle = {
+ type: 'pixel',
+ resolution: 64,
+}
export const defaultAvatarEyes: AvatarEyeDefaults = {
widthLeft: defaultExpression.widthLeft,
widthRight: defaultExpression.widthRight,
@@ -67,6 +78,22 @@ const parseColors = (value: unknown): AvatarColors => {
}
}
+const finiteBounded = (value: unknown, fallback: number, min: number, max: number) =>
+ typeof value === 'number' && Number.isFinite(value)
+ ? Math.min(max, Math.max(min, value))
+ : fallback
+
+export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
+ const candidate = value as Partial | null
+ if (candidate?.type !== 'pixel') return { ...defaultAvatarRenderStyle }
+ return {
+ type: 'pixel',
+ resolution: Math.round(
+ finiteBounded(candidate.resolution, defaultPixelRenderStyle.resolution, 8, 192)
+ ),
+ }
+}
+
const eyeDefaultFields = Object.keys(defaultAvatarEyes) as (keyof AvatarEyeDefaults)[]
export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => {
const candidate = value as Partial | null
@@ -174,6 +201,7 @@ export const createAvatar = (name: string): StudioAvatar => ({
body: { primary: { ...surfacePresets.sphere }, nodes: [] },
colors: { ...defaultAvatarColors },
eyes: { ...defaultAvatarEyes },
+ renderStyle: { ...defaultAvatarRenderStyle },
})
export const parseAvatarLibrary = (
@@ -201,6 +229,7 @@ export const parseAvatarLibrary = (
body: parseAvatarBody(avatar.body, surfacePresets.sphere),
colors: parseColors(avatar.colors),
eyes: parseAvatarEyeDefaults(avatar.eyes),
+ renderStyle: parseAvatarRenderStyle(avatar.renderStyle),
...(behavior ? { behavior } : {}),
}
})
diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx
index 808dbdb..dde381f 100644
--- a/src/features/avatar/components/ExpressionWorkspace.tsx
+++ b/src/features/avatar/components/ExpressionWorkspace.tsx
@@ -15,12 +15,17 @@ import { useStudioLanguage } from '@/i18n'
import { ControlSection } from '@/app/components/common'
import { AmbientMotionField, ColorField, LinkButton, NumericField } from '@/app/components/controls'
import { emptyBodyNodes, getPreviewGeometry, resolveColors, type Side } from '@/app/studio-utils'
-import { type AvatarColors, type AvatarEyeDefaults } from '@/features/avatar/avatars'
+import {
+ type AvatarColors,
+ type AvatarEyeDefaults,
+ type AvatarRenderStyle,
+} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body'
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
import { type Expression } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
+import { StaticPixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
return (
@@ -39,6 +44,7 @@ export function ExpressionPreview({
bodyNodes,
colors,
avatarEyes,
+ renderStyle,
id,
}: {
expression: Expression
@@ -46,13 +52,35 @@ export function ExpressionPreview({
bodyNodes: BodyNode[]
colors: AvatarColors
avatarEyes: AvatarEyeDefaults
+ renderStyle: AvatarRenderStyle
id: string
}) {
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
const resolvedColors = resolveColors(expression, colors)
+ if (renderStyle.type === 'pixel') {
+ return (
+
+ )
+ }
const clipId = `preview-${id}`
return (
-