feat(avatar): add pixel rendering across studio

Persist the avatar render style and apply it consistently to previews, snapshots, animations, and standalone exports.
This commit is contained in:
smontlouis 2026-08-15 11:41:30 +02:00
parent 8207a2d6aa
commit e205c57687
21 changed files with 716 additions and 51 deletions

View File

@ -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,10 +62,29 @@ 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 (
<div className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}>
<svg viewBox="-150 -150 300 300" aria-hidden="true">
<div
className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}
style={renderStyle.type === 'pixel' ? { background: pixelBackground } : undefined}
>
{renderStyle.type === 'pixel' ? (
<LivePixelAvatarCanvas
scene={scene}
colors={colors}
style={renderStyle}
className="avatar-preview"
/>
) : (
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>
<motion.path d={scene.headPath} />
@ -92,6 +115,7 @@ export function SnapshotPreview({
))}
</motion.g>
</svg>
)}
</div>
)
}

View File

@ -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;

View File

@ -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<HTMLButtonElement | null>
reduceMotion: boolean
@ -252,6 +258,7 @@ export function SequenceWorkspace({
bodyNodes={bodyNodes}
colors={colors}
avatarEyes={avatarEyes}
renderStyle={renderStyle}
id={`sequence-${editing.draft.id}-${step.id}`}
/>
<span>{String(expressionIndex).padStart(2, '0')}</span>
@ -389,6 +396,7 @@ export function SequenceWorkspace({
bodyNodes={bodyNodes}
colors={colors}
avatarEyes={avatarEyes}
renderStyle={renderStyle}
id={`sequence-library-${index}`}
/>
<span>{String(index).padStart(2, '0')}</span>

View File

@ -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,

View File

@ -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<PixelRenderStyle> | 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<AvatarEyeDefaults> | 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 } : {}),
}
})

View File

@ -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 (
<StaticPixelAvatarCanvas
className="avatar-preview"
style={renderStyle}
frame={{
headPath: geometry.headPath,
backPaths: geometry.backPaths,
frontPaths: geometry.frontPaths,
leftPath: geometry.leftPath,
rightPath: geometry.rightPath,
leftOpacity: geometry.leftVisible ? 1 : 0,
rightOpacity: geometry.rightVisible ? 1 : 0,
offsetX: 0,
offsetY: 0,
bodyColor: resolvedColors.body,
eyeColor: resolvedColors.eyes,
}}
/>
)
}
const clipId = `preview-${id}`
return (
<svg viewBox="-150 -150 300 300" aria-hidden="true">
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>
<path d={geometry.headPath} />
@ -101,6 +129,7 @@ export function ExpressionCard({
bodyNodes,
colors,
avatarEyes,
renderStyle,
previewId,
onSelect,
onEdit,
@ -120,6 +149,7 @@ export function ExpressionCard({
bodyNodes: BodyNode[]
colors: AvatarColors
avatarEyes: AvatarEyeDefaults
renderStyle: AvatarRenderStyle
previewId: string
onSelect: () => void
onEdit?: () => void
@ -154,6 +184,7 @@ export function ExpressionCard({
bodyNodes={bodyNodes}
colors={colors}
avatarEyes={avatarEyes}
renderStyle={renderStyle}
id={previewId}
/>
<span>{String(index).padStart(2, '0')}</span>

View File

@ -28,6 +28,20 @@ describe('avatar export', () => {
)
expect(payload).not.toHaveProperty('frames')
expect(payload.avatar.name).toBe('Strobi')
expect(payload.avatar.renderStyle).toEqual({ type: 'vector' })
})
it('preserves pixel rendering in standalone exports', () => {
const pixelPayload = createAvatarExportPayload(
{ ...avatar, renderStyle: { type: 'pixel', resolution: 72 } },
initialExpressions,
animations
)
const source = generateJavaScriptAvatarModule(pixelPayload)
expect(pixelPayload.avatar.renderStyle).toEqual({ type: 'pixel', resolution: 72 })
expect(source).toContain('paintPixelGeometry')
expect(source).toContain('Math.max(8, Math.min(192')
})
it('generates a standalone JavaScript module without a Web Component', () => {

View File

@ -1,7 +1,11 @@
import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets'
import { createRenderedScene, paintRenderedOffset } from '@/features/rendering/renderedScene'
import { serializeAvatarSnapshot, snapshotFileName } from '@/features/export/snapshotExporter'
import {
serializeAvatarSnapshot,
serializePixelSnapshot,
snapshotFileName,
} from '@/features/export/snapshotExporter'
import { surfacePresets } from '@/features/avatar/surfaces'
describe('avatar snapshot export', () => {
@ -38,4 +42,13 @@ describe('avatar snapshot export', () => {
expect(snapshotFileName('Étoile du soir')).toBe('etoile-du-soir-snapshot.svg')
expect(snapshotFileName('Étoile du soir', 'png')).toBe('etoile-du-soir-snapshot.png')
})
it('embeds a pixel snapshot as a self-contained raster SVG', () => {
const svg = serializePixelSnapshot('Pixel & Co', 'data:image/png;base64,AAAA', 256)
expect(svg).toContain('viewBox="0 0 256 256"')
expect(svg).toContain('image-rendering="pixelated"')
expect(svg).toContain('data:image/png;base64,AAAA')
expect(svg).toContain('aria-label="Pixel &amp; Co"')
})
})

View File

@ -23,6 +23,7 @@ export type AvatarExportPayload = {
surface: StudioAvatar['body']['primary']
bodyNodes: StudioAvatar['body']['nodes']
colors: StudioAvatar['colors']
renderStyle: StudioAvatar['renderStyle']
}
expressions: Record<string, Expression>
animations: Record<string, AvatarExportAnimation>
@ -92,6 +93,7 @@ export const createAvatarExportPayload = (
surface: avatar.body.primary,
bodyNodes: avatar.body.nodes,
colors: avatar.colors,
renderStyle: avatar.renderStyle,
},
expressions: exportedExpressions,
animations,

View File

@ -40,6 +40,51 @@ const resolveColors = expression => ({
eyes: expression.eyeColor || DATA.avatar.colors.eyes,
});
const svgElement = name => document.createElementNS(SVG_NS, name);
const canvasPath = (context, value, color, opacity = 1) => {
if (!value || opacity <= 0) return;
context.globalAlpha = opacity;
context.fillStyle = color;
context.fill(new Path2D(value));
};
const paintPixelGeometry = (context, geometry, offset, colors, resolution) => {
const scale = resolution / 300;
context.clearRect(0, 0, resolution, resolution);
context.imageSmoothingEnabled = false;
context.save();
context.setTransform(scale, 0, 0, scale, resolution / 2, resolution / 2);
context.translate(offset.x, offset.y);
geometry.backPaths.forEach(value => canvasPath(context, value, colors.body));
canvasPath(context, geometry.headPath, colors.body);
context.save();
context.clip(new Path2D(geometry.headPath));
canvasPath(context, geometry.leftPath, colors.eyes, geometry.leftVisible ? 1 : 0);
canvasPath(context, geometry.rightPath, colors.eyes, geometry.rightVisible ? 1 : 0);
context.restore();
geometry.frontPaths.forEach(value => canvasPath(context, value, colors.body));
context.restore();
const image = context.getImageData(0, 0, resolution, resolution);
const body = colorChannels(colors.body);
const eyes = colorChannels(colors.eyes);
for (let index = 0; index < image.data.length; index += 4) {
if (image.data[index + 3] < 128) {
image.data[index] = 0;
image.data[index + 1] = 0;
image.data[index + 2] = 0;
image.data[index + 3] = 0;
continue;
}
const bodyDistance = (image.data[index] - body[0]) ** 2 +
(image.data[index + 1] - body[1]) ** 2 + (image.data[index + 2] - body[2]) ** 2;
const eyeDistance = (image.data[index] - eyes[0]) ** 2 +
(image.data[index + 1] - eyes[1]) ** 2 + (image.data[index + 2] - eyes[2]) ** 2;
const color = bodyDistance <= eyeDistance ? body : eyes;
image.data[index] = color[0];
image.data[index + 1] = color[1];
image.data[index + 2] = color[2];
image.data[index + 3] = 255;
}
context.putImageData(image, 0, 0);
};
function mountAvatar(target, options = {}) {
const host = typeof target === 'string' ? document.querySelector(target) : target;
@ -56,6 +101,18 @@ function mountAvatar(target, options = {}) {
svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
svg.style.display = 'block';
svg.style.overflow = 'visible';
const pixelStyle = DATA.avatar.renderStyle?.type === 'pixel' ? DATA.avatar.renderStyle : null;
const canvas = document.createElement('canvas');
const pixelResolution = pixelStyle ? Math.max(8, Math.min(192, Math.round(pixelStyle.resolution))) : 64;
canvas.width = pixelResolution;
canvas.height = pixelResolution;
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', DATA.avatar.name);
canvas.style.width = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
canvas.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
canvas.style.display = 'block';
canvas.style.imageRendering = 'pixelated';
const pixelContext = canvas.getContext('2d', { willReadFrequently: true });
const defs = svgElement('defs');
const clipPath = svgElement('clipPath');
const clipHead = svgElement('path');
@ -74,7 +131,8 @@ function mountAvatar(target, options = {}) {
eyesLayer.append(leftEye, rightEye);
motionLayer.append(backLayer, head, eyesLayer, frontLayer);
svg.append(motionLayer);
host.replaceChildren(svg);
const renderElement = pixelStyle ? canvas : svg;
host.replaceChildren(renderElement);
const ensurePaths = (group, paths, fill) => {
while (group.children.length < paths.length) group.append(svgElement('path'));
@ -137,6 +195,10 @@ function mountAvatar(target, options = {}) {
eyeOffset,
});
const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, bodyElapsed, ambientStrength);
if (pixelStyle && pixelContext) {
paintPixelGeometry(pixelContext, geometry, offset, currentColors, pixelResolution);
return;
}
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
ensurePaths(backLayer, geometry.backPaths, currentColors.body);
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
@ -271,7 +333,7 @@ function mountAvatar(target, options = {}) {
stepTimer = setTimeout(() => advance(animation), duration);
};
const api = {
element: svg,
element: renderElement,
get animation() { return currentAnimation; },
get playing() { return playing; },
play(animationName) {
@ -353,7 +415,7 @@ function mountAvatar(target, options = {}) {
destroy() {
clearSchedule();
if (frameRequest !== null) cancelAnimationFrame(frameRequest);
svg.remove();
renderElement.remove();
},
};
applyMotion(initialExpression);

View File

@ -71,6 +71,12 @@ export const serializeAvatarSnapshot = (
</svg>`
}
export const serializePixelSnapshot = (name: string, imageDataUrl: string, size: number) =>
`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="${escapeXml(name)}">
<image href="${escapeXml(imageDataUrl)}" width="${size}" height="${size}" image-rendering="pixelated"/>
</svg>`
export const snapshotFileName = (name: string, extension: 'svg' | 'png' = 'svg') => {
const slug =
name

View File

@ -0,0 +1,19 @@
import { quantizePixelArtPixels } from '@/features/rendering/pixelRenderer'
describe('pixel art renderer', () => {
it('removes antialiased transparency and mixed edge colors', () => {
const pixels = new Uint8ClampedArray([91, 127, 229, 90, 88, 122, 221, 220, 24, 25, 31, 255])
quantizePixelArtPixels(pixels, '#5b7fe5', '#111316')
expect([...pixels]).toEqual([0, 0, 0, 0, 91, 127, 229, 255, 17, 19, 22, 255])
})
it('uses a deterministic hard threshold for boundary pixels', () => {
const pixels = new Uint8ClampedArray([91, 127, 229, 127, 91, 127, 229, 128])
quantizePixelArtPixels(pixels, '#5b7fe5', '#111316')
expect([...pixels]).toEqual([0, 0, 0, 0, 91, 127, 229, 255])
})
})

View File

@ -13,7 +13,11 @@ import {
type Highlight,
type PlaybackStatus,
} from '@/app/studio-utils'
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '@/features/avatar/avatars'
import {
applyAvatarEyeDefaults,
type AvatarEyeDefaults,
type AvatarRenderStyle,
} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body'
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
import {
@ -40,8 +44,14 @@ import {
} from '@/features/avatar/manipulationSession'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { type RenderedRotationGizmo } from '@/features/rendering/renderedRotationGizmo'
import { findBodyNodePath, type RenderedScene } from '@/features/rendering/renderedScene'
import {
findBodyNodePath,
type RenderedColors,
type RenderedScene,
} from '@/features/rendering/renderedScene'
export function RotationGizmo({
expression,
rendered,
@ -471,6 +481,8 @@ export function AvatarCanvas({
avatarEyes,
surface,
scene,
colors,
renderStyle,
rotationGizmo,
showWire,
bodyEditing,
@ -495,6 +507,8 @@ export function AvatarCanvas({
avatarEyes: AvatarEyeDefaults
surface: SurfaceConfig
scene: RenderedScene
colors: RenderedColors
renderStyle: AvatarRenderStyle
rotationGizmo: RenderedRotationGizmo
showWire: boolean
bodyEditing: boolean
@ -744,7 +758,7 @@ export function AvatarCanvas({
}
useEscapeToCancel(cancelDrag)
return (
<div className="avatar-wrap">
<div className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}`}>
{playback && (
<motion.div
className="stage-playback-status"
@ -755,6 +769,14 @@ export function AvatarCanvas({
<PlaybackIdentity name={playback.name} status={playback.status} />
</motion.div>
)}
{renderStyle.type === 'pixel' && (
<LivePixelAvatarCanvas
scene={scene}
colors={colors}
style={renderStyle}
className="avatar-pixel-canvas"
/>
)}
<svg
ref={svgRef}
className="avatar"

View File

@ -0,0 +1,103 @@
import { useEffect, useRef } from 'react'
import type { PixelRenderStyle } from '@/features/avatar/avatars'
import { paintPixelAvatar, type PixelAvatarFrame } from '@/features/rendering/pixelRenderer'
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
export function StaticPixelAvatarCanvas({
frame,
style,
className,
}: {
frame: PixelAvatarFrame
style: PixelRenderStyle
className: string
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
const context = canvas?.getContext('2d', { willReadFrequently: true })
if (!canvas || !context) return
canvas.width = style.resolution
canvas.height = style.resolution
paintPixelAvatar(context, frame, style)
}, [frame, style])
return <canvas ref={canvasRef} className={className} aria-hidden="true" />
}
export function LivePixelAvatarCanvas({
scene,
colors,
style,
className,
}: {
scene: RenderedScene
colors: RenderedColors
style: PixelRenderStyle
className: string
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
const context = canvas?.getContext('2d', { willReadFrequently: true })
if (!canvas || !context) return
canvas.width = style.resolution
canvas.height = style.resolution
let frameRequest: number | null = null
const readPaths = (paths: RenderedScene['backPaths']) => {
const values: string[] = []
paths.forEach(path => {
const value = path.get()
if (value) values.push(value)
})
return values
}
const paint = () => {
frameRequest = null
paintPixelAvatar(
context,
{
headPath: scene.headPath.get(),
backPaths: readPaths(scene.backPaths),
frontPaths: readPaths(scene.frontPaths),
leftPath: scene.leftPath.get(),
rightPath: scene.rightPath.get(),
leftOpacity: scene.leftOpacity.get(),
rightOpacity: scene.rightOpacity.get(),
offsetX: scene.offsetX.get(),
offsetY: scene.offsetY.get(),
bodyColor: colors.body.get(),
eyeColor: colors.eyes.get(),
},
style
)
}
const schedulePaint = () => {
if (frameRequest === null) frameRequest = requestAnimationFrame(paint)
}
const values = [
scene.headPath,
...scene.backPaths,
...scene.frontPaths,
scene.leftPath,
scene.rightPath,
scene.leftOpacity,
scene.rightOpacity,
scene.offsetX,
scene.offsetY,
colors.body,
colors.eyes,
]
const unsubscribers = values.map(value => value.on('change', schedulePaint))
paint()
return () => {
unsubscribers.forEach(unsubscribe => unsubscribe())
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
}
}, [colors, scene, style])
return <canvas ref={canvasRef} className={className} aria-hidden="true" />
}

View File

@ -0,0 +1,93 @@
import type { PixelRenderStyle } from '@/features/avatar/avatars'
export type PixelAvatarFrame = {
headPath: string
backPaths: string[]
frontPaths: string[]
leftPath: string
rightPath: string
leftOpacity: number
rightOpacity: number
offsetX: number
offsetY: number
bodyColor: string
eyeColor: string
}
const fillPath = (context: CanvasRenderingContext2D, path: string, color: string, opacity = 1) => {
if (!path || opacity <= 0) return
context.globalAlpha = opacity
context.fillStyle = color
context.fill(new Path2D(path))
}
const colorChannels = (color: string): readonly [number, number, number] => [
Number.parseInt(color.slice(1, 3), 16),
Number.parseInt(color.slice(3, 5), 16),
Number.parseInt(color.slice(5, 7), 16),
]
const colorDistance = (
red: number,
green: number,
blue: number,
target: readonly [number, number, number]
) => (red - target[0]) ** 2 + (green - target[1]) ** 2 + (blue - target[2]) ** 2
export const quantizePixelArtPixels = (
pixels: Uint8ClampedArray,
bodyColor: string,
eyeColor: string,
alphaThreshold = 128
) => {
const body = colorChannels(bodyColor)
const eyes = colorChannels(eyeColor)
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index + 3] < alphaThreshold) {
pixels[index] = 0
pixels[index + 1] = 0
pixels[index + 2] = 0
pixels[index + 3] = 0
continue
}
const color =
colorDistance(pixels[index], pixels[index + 1], pixels[index + 2], body) <=
colorDistance(pixels[index], pixels[index + 1], pixels[index + 2], eyes)
? body
: eyes
pixels[index] = color[0]
pixels[index + 1] = color[1]
pixels[index + 2] = color[2]
pixels[index + 3] = 255
}
}
export const paintPixelAvatar = (
context: CanvasRenderingContext2D,
frame: PixelAvatarFrame,
style: PixelRenderStyle
) => {
const resolution = style.resolution
const scale = resolution / 300
context.clearRect(0, 0, resolution, resolution)
context.imageSmoothingEnabled = false
context.save()
context.setTransform(scale, 0, 0, scale, resolution / 2, resolution / 2)
context.translate(frame.offsetX, frame.offsetY)
frame.backPaths.forEach(path => fillPath(context, path, frame.bodyColor))
fillPath(context, frame.headPath, frame.bodyColor)
context.save()
context.clip(new Path2D(frame.headPath))
fillPath(context, frame.leftPath, frame.eyeColor, frame.leftOpacity)
fillPath(context, frame.rightPath, frame.eyeColor, frame.rightOpacity)
context.restore()
frame.frontPaths.forEach(path => fillPath(context, path, frame.bodyColor))
context.restore()
const image = context.getImageData(0, 0, resolution, resolution)
quantizePixelArtPixels(image.data, frame.bodyColor, frame.eyeColor)
context.putImageData(image, 0, 0)
}

View File

@ -99,6 +99,7 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
bodyNodes={avatar.body.nodes}
colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes}
renderStyle={avatar.renderStyle}
id={`avatar-${avatar.id}`}
/>
<span>{avatar.name}</span>

View File

@ -50,7 +50,11 @@ import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils'
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
import { defaultAvatarEyes } from '@/features/avatar/avatars'
import {
defaultAvatarEyes,
defaultPixelRenderStyle,
type AvatarRenderStyle,
} from '@/features/avatar/avatars'
import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body'
import {
ExpressionCard,
@ -188,6 +192,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
toggleStatePlayback,
transitionToExpression,
updateAvatarColors,
updateAvatarRenderStyle,
updateAvatarEyeDimension,
updateAvatarEyePosition,
updateAvatarEyeSize,
@ -203,6 +208,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
updateWireVisibility,
workspaceBackButtonRef,
} = controller
const pixelRenderStyle =
activeAvatar.renderStyle.type === 'pixel' ? activeAvatar.renderStyle : null
const playbackFooterY = useMotionValue(0)
const playbackHandleY = useMotionValue(0)
const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
@ -289,6 +296,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
selectedStepId={selectedSequenceStepId}
backButtonRef={workspaceBackButtonRef}
reduceMotion={Boolean(reduceMotion)}
@ -792,6 +800,62 @@ export function StudioInspector({ controller }: { controller: StudioController }
/>
</InspectorCard>
</ControlSection>
<ControlSection
title="Rendu"
subtitle="Choisis la finition visuelle propre à cet avatar."
>
<InspectorCard className="render-style-panel">
<PanelTitle
level={3}
title="Type de rendu"
subtitle="Pixel utilise une palette franche, sans lissage ni couleur intermédiaire."
/>
<Field className="render-style-field" orientation="horizontal">
<FieldTitle>{t('Style')}</FieldTitle>
<Select
value={activeAvatar.renderStyle.type}
items={[
{ value: 'vector', label: t('Vectoriel') },
{ value: 'pixel', label: t('Pixel') },
]}
onValueChange={next => {
if (!next) return
const renderStyle: AvatarRenderStyle =
next === 'pixel'
? { ...defaultPixelRenderStyle }
: { type: 'vector' }
updateAvatarRenderStyle(renderStyle)
}}
>
<SelectTrigger aria-label={t('Type de rendu')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="vector">{t('Vectoriel')}</SelectItem>
<SelectItem value="pixel">{t('Pixel')}</SelectItem>
</SelectContent>
</Select>
</Field>
{pixelRenderStyle && (
<div className="pixel-render-options">
<NumericField
label="Définition de la grille"
value={pixelRenderStyle.resolution}
min={8}
max={192}
step={8}
unit="px"
onChange={resolution =>
updateAvatarRenderStyle({
...pixelRenderStyle,
resolution: Math.round(resolution),
})
}
/>
</div>
)}
</InspectorCard>
</ControlSection>
<ControlSection
title="Yeux"
subtitle="Forme, placement, orientation et couleur du regard par défaut."
@ -1349,6 +1413,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
previewId={String(index)}
onSelect={() => transitionToExpression(preset, index)}
onEdit={() => openExpressionEditor(index, preset)}
@ -1495,6 +1560,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`}
/>
<span>{sequence.builtIn ? t(sequence.name) : sequence.name}</span>
@ -1578,6 +1644,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`export-avatar-${activeAvatar.id}`}
/>
<div>
@ -1668,6 +1735,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`export-animation-${animation.id}`}
/>
<span>{animation.builtIn ? t(animation.name) : animation.name}</span>
@ -1704,6 +1772,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
background={snapshotBackground}
colorFrom={snapshotColorFrom}
colorTo={snapshotColorTo}
renderStyle={activeAvatar.renderStyle}
/>
<div>
<small>{t('Aperçu du mode photo')}</small>
@ -1914,6 +1983,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`player-${activeSequence.id}-${position}`}
/>
{playbackVisual.position === position && (
@ -2016,6 +2086,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`active-avatar-tab-${activeAvatar.id}`}
/>
<span>{activeAvatar.name}</span>

View File

@ -12,6 +12,7 @@ import type { StudioController } from '@/features/studio/useStudioController'
export function StudioStage({ controller }: { controller: StudioController }) {
const [photoHelpOpen, setPhotoHelpOpen] = useState(false)
const {
activeAvatar,
activeAvatarEyes,
activeSequenceLabel,
bodyEditing,
@ -67,6 +68,8 @@ export function StudioStage({ controller }: { controller: StudioController }) {
avatarEyes={activeAvatarEyes}
surface={surface}
scene={renderedScene}
colors={renderedColors}
renderStyle={activeAvatar.renderStyle}
rotationGizmo={renderedRotationGizmo}
showWire={showWire}
bodyEditing={bodyEditing}

View File

@ -53,6 +53,7 @@ import {
type AvatarBehaviorLibrary,
type AvatarColors,
type AvatarEyeDefaults,
type AvatarRenderStyle,
type StudioAvatar,
} from '@/features/avatar/avatars'
import {
@ -84,6 +85,7 @@ import {
} from '@/features/export/exporter'
import {
serializeAvatarSnapshot,
serializePixelSnapshot,
snapshotFileName,
type SnapshotBackground,
} from '@/features/export/snapshotExporter'
@ -102,6 +104,7 @@ import {
paintRenderedOffset,
paintRenderedScene,
} from '@/features/rendering/renderedScene'
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
import {
createStudioDocumentStore,
loadStudioDocument,
@ -745,6 +748,10 @@ export function useStudioController() {
setDisplayColors(resolveColors(expression, colors))
}
const updateAvatarRenderStyle = (renderStyle: AvatarRenderStyle) => {
updateActiveAvatar(avatar => ({ ...avatar, renderStyle }))
}
const updateAvatarEyes = (changes: Partial<AvatarEyeDefaults>) => {
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
if (!avatar) return
@ -1524,13 +1531,92 @@ export function useStudioController() {
}
)
const createPixelSnapshotCanvas = () => {
const renderStyle = activeAvatar.renderStyle
if (renderStyle.type !== 'pixel') return null
const size = Number(snapshotSize)
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
if (!context) return null
if (snapshotBackground === 'solid') {
context.fillStyle = snapshotColorFrom
context.fillRect(0, 0, size, size)
} else if (snapshotBackground === 'linear') {
const gradient = context.createLinearGradient(0, 0, size, size)
gradient.addColorStop(0, snapshotColorFrom)
gradient.addColorStop(1, snapshotColorTo)
context.fillStyle = gradient
context.fillRect(0, 0, size, size)
} else if (snapshotBackground === 'radial') {
const gradient = context.createRadialGradient(
size * 0.5,
size * 0.42,
0,
size * 0.5,
size * 0.42,
size * 0.7
)
gradient.addColorStop(0, snapshotColorFrom)
gradient.addColorStop(1, snapshotColorTo)
context.fillStyle = gradient
context.fillRect(0, 0, size, size)
}
const avatarCanvas = document.createElement('canvas')
avatarCanvas.width = renderStyle.resolution
avatarCanvas.height = renderStyle.resolution
const avatarContext = avatarCanvas.getContext('2d', { willReadFrequently: true })
if (!avatarContext) return null
paintPixelAvatar(
avatarContext,
{
headPath: renderedScene.headPath.get(),
backPaths: renderedScene.backPaths.flatMap(item => {
const value = item.get()
return value ? [value] : []
}),
frontPaths: renderedScene.frontPaths.flatMap(item => {
const value = item.get()
return value ? [value] : []
}),
leftPath: renderedScene.leftPath.get(),
rightPath: renderedScene.rightPath.get(),
leftOpacity: renderedScene.leftOpacity.get(),
rightOpacity: renderedScene.rightOpacity.get(),
offsetX: renderedScene.offsetX.get(),
offsetY: renderedScene.offsetY.get(),
bodyColor: renderedColors.body.get(),
eyeColor: renderedColors.eyes.get(),
},
renderStyle
)
context.imageSmoothingEnabled = false
context.drawImage(avatarCanvas, 0, 0, size, size)
return canvas
}
const downloadSnapshotSvg = () => {
const pixelCanvas = createPixelSnapshotCanvas()
const source = pixelCanvas
? serializePixelSnapshot(
activeAvatar.name,
pixelCanvas.toDataURL('image/png'),
Number(snapshotSize)
)
: currentSnapshotSvg()
downloadBlob(
new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }),
new Blob([source], { type: 'image/svg+xml;charset=utf-8' }),
snapshotFileName(activeAvatar.name)
)
}
const downloadSnapshotPng = () => {
const pixelCanvas = createPixelSnapshotCanvas()
if (pixelCanvas) {
pixelCanvas.toBlob(blob => {
if (blob) downloadBlob(blob, snapshotFileName(activeAvatar.name, 'png'))
}, 'image/png')
return
}
const size = Number(snapshotSize)
const source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' })
const sourceUrl = URL.createObjectURL(source)
@ -1802,6 +1888,7 @@ export function useStudioController() {
toggleStatePlayback,
transitionToExpression,
updateAvatarColors,
updateAvatarRenderStyle,
updateAvatarEyeDimension,
updateAvatarEyePosition,
updateAvatarEyeSize,

View File

@ -64,6 +64,15 @@ const english: Record<string, string> = {
'L’avatar à gauche affiche cette expression en direct.':
'The avatar on the left previews this expression live.',
Corps: 'Body',
Rendu: 'Rendering',
'Choisis la finition visuelle propre à cet avatar.':
'Choose the visual finish specific to this avatar.',
'Type de rendu': 'Rendering type',
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
'Pixel uses a hard palette, with no smoothing or intermediate colors.',
Vectoriel: 'Vector',
Pixel: 'Pixel',
'Définition de la grille': 'Grid resolution',
'Apparence et orientation générale de l’avatar.': 'General avatar appearance and orientation.',
'Couleur du corps': 'Body color',
'Rotation de la tête': 'Head rotation',

View File

@ -36,6 +36,14 @@ export const chinese: Record<string, string> = {
'Nouvelle expression': '新建表情',
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
Corps: '身体',
Rendu: '渲染',
'Choisis la finition visuelle propre à cet avatar.': '选择此头像专属的视觉效果。',
'Type de rendu': '渲染类型',
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
'像素渲染使用纯色调色板,不进行平滑处理,也不产生中间色。',
Vectoriel: '矢量',
Pixel: '像素',
'Définition de la grille': '网格分辨率',
'Apparence et orientation générale de l’avatar.': '头像的整体外观和方向。',
'Couleur du corps': '身体颜色',
'Rotation de la tête': '头部旋转',