Files
PaperClipAI/packages/shared/src/cliplab/renderer.ts
T
86b7ee992c feat(onboarding): ClipLab sleepy-to-wake hero and step hand-offs (#13629)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents have a persistent visual identity (#13171): a ClipLab
character in one of 17 palettes, rendered as cached PNGs in lists and as
a live character in larger placements.
> - The onboarding wizard is where a person meets that identity first,
and it showed a stock ClipLab expression on the previous engine while
the rest of the app would show a different character on a newer one.
> - The wizard's steps also cut from one screen to the next, so the arc
read as separate pages rather than one walk.
> - This pull request puts one character on one engine everywhere, gives
the wizard's hero the studio's sleepy → wink → idle sequence on Review,
and hands the steps over inside one presence.
> - The benefit is that what wakes on Review is exactly what the agent
looks like on the dashboard afterwards, and the walk to it reads as one
screen changing.

## Linked Issues or Issue Description

Refs #13171, now merged into master. This PR contains the onboarding and
ClipLab update on top of that foundation. Original feature work by
@tonio-alucema; merge preparation preserves the original commits.

**Problem or motivation**

The onboarding hero and the app's avatars were two different characters
on two different ClipLab engines. Steps 1 → 4 of the wizard cut between
screens, and the wizard mounted cold when a cloud-managed workspace
arrived from Cloud's naming screen.

**Proposed solution**

Vendor ClipLab v0.2.0 as the shared engine and render one
studio-exported character from it in every palette, for every pose and
size. Play the export's one-shot wake on Review with the palette fading
in over the gray dormant loop. Hand steps over inside one presence so
the footer slides instead of jumping, and play the arrival half of that
hand-off when the wizard opens directly on the agent step.

**Alternatives considered**

Exporting mp4/webm loops per size: no cursor following, no clean alpha,
and the palette "colour in" is a runtime blend. Minting a `cap-v2`
character version: nothing had shipped `cap-v1`, so the artwork is
regenerated in place instead of migrated. Keeping the separately
vendored runtime bundle for the hero: two engines and two characters in
one app.

## What Changed

- `packages/shared/src/cliplab`: re-vendored from ClipLab v0.2.0
(`987b6db0`) with the Paperclip adaptations replayed (optional graphics
backend for the Node SVG snapshot path, supersampled live textures,
character framing, deterministic SVG id prefixes); new upstream
`particles.ts`.
- `packages/shared/src/cliplab/character.ts`: the studio export,
mirrored from `ui/src/assets/cliplab/onboarding.character.json` by
`scripts/sync-cliplab-character.mjs` (drift caught by
`check:token-gates`). `characterDefinition` builds every palette from
it; the resting portrait is its idle beat.
- `OnboardingCharacter`: gray `sleepy` loop through the agent and
connect steps; on Review the one-shot sleepy → wink → idle plays on two
lock-step canvases while the palette fades in, then the `idle` loop.
Body-follows the pointer, page-scoped. 160px in the wizard.
- `OnboardingWizard`: steps 1 → 2 → 3 → 4 hand over inside one
`AnimatePresence` (departing content fades and gives its room back;
arriving content opens its room then fills); the hero has a room that
opens on the walk into the agent step; opening directly on the agent
step plays the arrival half; the self-hosted naming step uses the arc's
label and field.
- Motion vocabulary in `onboarding-motion.ts` (`stepContentMotion`,
`ledeMotion`, `heroRoomMotion`, `heroRoomArrival`, `titleSwapMotion`).
- Storybook: `Onboarding / Character` (Wake Up), `Onboarding / Agent
arc` walkable from the naming step plus `Arrive From Cloud`; the
companies fixture answers the wizard's create call with a company.
- Uses the shared runtime for onboarding; `doc/agent-personas.md`
documents the shared character.
- Releases both onboarding canvases after partial startup or transition
failure. Registers each canvas before seeking so synchronous render
errors can release it. Six component tests cover these failures and
palette changes before or during wake.
- Refreshes both sleeping canvases when the palette changes, including a
palette change in the same render as wake.
- Moves choreography values into the CSS token layer and preserves the
shared motion catalog drift check across the imported stylesheet.
- Repairs the static Storybook avatar route and uses accessible heading
names/current button labels in the wizard play functions.
- Closes the lazy avatar worker pool during application shutdown.

## Verification

- Merge-preparation checks: `pnpm -r typecheck`, `pnpm build`, `pnpm
build-storybook`, and `pnpm check:token-gates` pass. The final UI
typecheck and 123 focused onboarding, lifecycle, and token catalog tests
pass. All 55 checks on final head
`b4f5e201a1564083d163abc6f93f5b3da06ccefd` pass, including the full
sharded test suite, runner verification, and all eight browser shards
([CI
run](https://github.com/paperclipai/paperclip/actions/runs/35445430535)).
The duplicate monolithic local `pnpm test:run` was stopped after CI
completed; it is not claimed as a separate completed local run.
- Chromium walkthrough: palette change, wake, return to sleep, WebGL
failure fallback, Review step hand-offs and cloud arrival pass with
normal and reduced motion; no browser errors. The signoff happy-path
browser test also passes against a disposable instance.
- The final CI run confirms the catalog fix and a passing signoff
browser shard. The earlier signoff failure was a heartbeat-run
availability timeout; the focused local reproduction and final CI passed
without signoff code changes.
- Original author verification:
- `pnpm check:token-gates` (includes the new character sync check);
shared, server avatar/persona (17) and UI onboarding/persona (137)
suites pass; `pnpm build-storybook` packages all 3,564 avatar PNGs
through the worker pipeline.
- Storybook: `Agents / Personas` Sizes, Expressions and Palettes render
the studio character at every size and pose; `Onboarding / Character →
Wake Up` plays the wake on the shared engine; `Onboarding / Agent arc`
walks 1 → 4 with the hand-offs, and `Arrive From Cloud` plays the
arrival (measured: content room 6 → 65px over 320ms, fade to 1.0 by
~560ms, footer travel continuous).
- The original author walked the agent → connect → review flow and wake
after a real sign-in on staging.
- Not done here: the Linux Storybook visual baselines
(`tests/storybook-visual/agent-personas.spec.ts`) need re-baselining for
the new engine, hero size and naming-step changes.

## Risks

- Every avatar's pixels change (new engine, new character) under the
unchanged `cap-v1` name. Stacks that rendered avatars on the previous
engine keep those PNGs in their cache
(`generated-agent-avatars/cap-v1/...`, served immutable) until cleared;
only the two pinned staging stacks ever did.
- The one-shot handoff to the idle loop is timed from the sequence's
authored duration (the engine reports completion by continuing into idle
itself); presentation only, nothing in the wizard's state waits on it.
- Reduced motion skips the wake and the hand-offs; jsdom is treated the
same way, so the wizard tests see the next step's content immediately.
- The committed export differs from the studio by one animation (Loop
off, leading idle step removed); a re-export without that fix would play
a 5.6s idle before the wake.

## Model Used

Original feature: Anthropic Claude Fable 5.1 (`claude-fable-5-1`) in
Claude Code, with shell, browser, and file tools. The original context
window was not recorded.

Merge preparation and lifecycle regression fixes: OpenAI GPT-6 in Codex,
with reasoning, shell execution, file editing, GitHub CLI, and automated
tests. The session does not expose an exact runtime model ID or
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Dotta <bippadotta@protonmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 08:30:14 -05:00

483 lines
36 KiB
TypeScript

// ClipLab renderer adapted for Paperclip (optional graphics backend for Node SVG snapshots,
// supersampled live textures, character framing). Geometry and face code remain upstream v0.2.0.
// Vendored from ClipLab 987b6db0 (v0.2.0); see PROVENANCE.md and LICENSE.
import * as THREE from 'three'
import { particleLayout, type ParticleSettings } from './particles.js'
import { BASE_POSE, detailAt, faceLayers, type Character, type Detail, type FaceLayer, type Pose, type Sample, type Shape } from './model.js'
import { clampGaze, gazeAtPoint, irisOffset, localGaze, type EyeGazes, type Gaze, type PointerLook } from './gaze.js'
import { drawMorphBrow, drawMorphEye, drawMorphMouth, drawDrool, droolAnchor, mouthGeometry, traitWeight } from './face-morph.js'
export interface RenderOptions {
width: number; height: number; displaySize?: number; background?: string | null
cursor?: { x: number; y: number }; rotation?: { x: number; y: number; z: number }; zoom?: number; framing?: 'portrait' | 'character'; pixelRatio?: number
pointerLook?: PointerLook; eyeGazes?: EyeGazes; reducedMotion?: boolean
}
/** Cursor pitch owns the vertical look direction, independent of the resting tilt. */
export function characterRotation(character: Pick<Character, 'trueFront' | 'followRotation'>, pose: Pick<Pose, 'rotationX' | 'rotationY' | 'rotationZ'>, rotation = { x: -5.9, y: -24.2, z: 0 }, cursor: Gaze = { x: 0, y: 0 }) {
if (character.trueFront) return { x: 0, y: 0, z: 0 }
return {
x: character.followRotation ? -cursor.y * 16 : rotation.x + pose.rotationX,
y: character.followRotation ? cursor.x * 28 : rotation.y + pose.rotationY,
z: character.followRotation ? 0 : rotation.z + pose.rotationZ
}
}
/** Anchor the rounded highlight to the face direction shown by the orbit marker.
* Match the former cursor travel at 28° yaw / 16° pitch, with a bounded rim. */
export function toonLightOffset(orientation: THREE.Quaternion): Gaze {
const facing = new THREE.Vector3(0, 0, 1).applyQuaternion(orientation)
const look = clampGaze({ x: facing.x / Math.sin(28 * Math.PI / 180), y: facing.y / Math.sin(16 * Math.PI / 180) })
return { x: -.015 + look.x * .045, y: .015 + look.y * .045 }
}
/** Size adaptation is render-only; larger sizes restore the authored appearance. */
export function faceForSize(character: Character, sample: Sample, size: number) {
const simpleEyes = size > 12 && size <= 48
const frontOnly = size <= 32
const flatFill = size < 40 && size !== 32
const smallerSpacedEyes = size === 24 || size === 32
if (size === 16) sample = { ...sample, pose: { ...sample.pose, faceScale: sample.pose.faceScale * 1.2 } }
return {
character: simpleEyes || flatFill ? { ...character, ...(simpleEyes ? { iris: false } : {}), ...(flatFill ? { toon: false } : {}), ...(size < 40 ? { shadow: false } : {}), ...(frontOnly ? { trueFront: true, lockPosition: true, followRotation: false } : {}) } : character,
sample: simpleEyes || frontOnly ? { ...sample, ...(frontOnly ? { faceLayers: undefined } : {}), pose: { ...sample.pose, faceScale: sample.pose.faceScale * (simpleEyes ? 1.3 : 1), eyeSize: sample.pose.eyeSize * (size === 24 ? 1.2 : 1) * (size >= 16 && size <= 32 ? 1.1664 : 1) * (smallerSpacedEyes ? .8 : 1), ...(frontOnly ? { squash: 1 } : {}), ...(frontOnly ? { mouth: 'smile' as const, prop: 'none' as const, drool: false, tears: false, blush: 0, brows: 'none' as const, mouthWidth: BASE_POSE.mouthWidth * (size >= 16 ? 1.44 : 1), mouthStroke: BASE_POSE.mouthStroke * (size >= 16 ? 1.44 : 1), gazeX: -4, gazeY: 0, leftX: 0, rightX: 0, leftY: 0, rightY: 0, spacing: BASE_POSE.spacing * .77 * (smallerSpacedEyes ? 1.2 : 1), faceY: BASE_POSE.faceY + (size === 32 ? .025 * bodyHeight(character.shape) : 0) } : {}) } } : sample,
simpleEyes
}
}
export const bodyHeight = (shape: Shape) => shape === 'capsule' ? 2 : 1
export function radiusAt(shape: Shape, y: number): number {
if (shape === 'sphere') return Math.sqrt(Math.max(0, .25 - y * y))
if (shape === 'capsule') { const dy = Math.max(Math.abs(y) - .5, 0); return Math.sqrt(Math.max(0, .25 - dy * dy)) }
if (y >= 0) return Math.sqrt(Math.max(0, .25 - y * y))
if (y >= -.43) return .5
return .43 + Math.sqrt(Math.max(0, .07 ** 2 - (y + .43) ** 2))
}
function geometryFor(shape: Shape, squareBottom = false): THREE.BufferGeometry {
if (shape === 'sphere') return new THREE.SphereGeometry(.5, 80, 64)
if (shape === 'capsule') return new THREE.CapsuleGeometry(.5, 1, 24, 80)
const points = [new THREE.Vector2(0, -.5), new THREE.Vector2(squareBottom ? .5 : .43, -.5)]
for (let i = 1; !squareBottom && i <= 12; i++) { const a = -Math.PI / 2 + i / 12 * Math.PI / 2; points.push(new THREE.Vector2(.43 + .07 * Math.cos(a), -.43 + .07 * Math.sin(a))) }
points.push(new THREE.Vector2(.5, 0))
for (let i = 1; i <= 32; i++) { const a = i / 32 * Math.PI / 2; points.push(new THREE.Vector2(.5 * Math.cos(a), .5 * Math.sin(a))) }
return new THREE.LatheGeometry(points, 80)
}
const vertexShader = `
uniform vec3 fillScale;
uniform vec3 fillOffset;
varying vec3 vPosition;
void main() {
vPosition = position * fillScale + fillOffset;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const fragmentShader = `
uniform vec3 colorA;
uniform vec3 colorB;
uniform float gradientOn;
uniform float toonOn;
uniform float insetFill;
uniform float angle;
uniform float bodyHeight;
varying vec3 vPosition;
void main() {
float t = clamp(0.5 + vPosition.y / bodyHeight * cos(angle) + vPosition.x * sin(angle), 0.0, 1.0);
vec3 color = mix(colorA, mix(colorB, colorA, t), gradientOn);
// The light fill uses an inset copy of the silhouette, with one crisp shade step.
float shade = mix(0.82, 1.0, insetFill);
gl_FragColor = vec4(color * mix(1.0, shade, toonOn), 1.0);
#include <colorspace_fragment>
}
`
function heart(ctx: CanvasRenderingContext2D, x: number, y: number, r: number) {
ctx.beginPath(); ctx.moveTo(x, y + r)
ctx.bezierCurveTo(x - r * 2, y - r * .1, x - r, y - r * 1.5, x, y - r * .55)
ctx.bezierCurveTo(x + r, y - r * 1.5, x + r * 2, y - r * .1, x, y + r); ctx.closePath(); ctx.fill()
}
function star(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, points = 5) {
ctx.beginPath()
for (let i = 0; i < points * 2; i++) { const a = i / (points * 2) * Math.PI * 2 - Math.PI / 2; const d = i % 2 ? r * .46 : r; const px = x + Math.cos(a) * d, py = y + Math.sin(a) * d; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py) }
ctx.closePath(); ctx.fill()
}
const faceAspect = .76 / .57
const smoothstep = (a: number, b: number, x: number) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) }
const fract = (v: number) => ((v % 1) + 1) % 1
export function effectEnvelope(phase: number) { const t = fract(phase); return smoothstep(0, .18, t) * (1 - smoothstep(.72, 1, t)) }
export function eyeRadius(pose: Pose, character: Character, detail: Detail, side: number) { return (detail === 'eyes' ? 29 : 27) * pose.eyeSize * (side < 0 ? pose.leftScale : pose.rightScale) * (character.iris ? 1.2 : 1) }
export function projectedEye(character: Character, pose: Pose, blink: number, side: number, matrix: THREE.Matrix4, camera: THREE.Camera, layers?: FaceLayer[]) {
const radius = eyeRadius(pose, character, 'full', side)
const cx = 256 + side * 103 * pose.spacing + (side < 0 ? pose.leftX : pose.rightX)
const cy = 197 - (side < 0 ? pose.leftY : pose.rightY)
const angle = (pose.eyeTilt * side + (side < 0 ? pose.leftRotation : pose.rightRotation)) * Math.PI / 180
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (1 - .35 * (layers ? traitWeight(layers, t => t.eye === 'soft') : pose.eye === 'soft' ? 1 : 0))
const shell = character.elevated ? 1 + character.elevation : 1.003
const project = (dx: number, dy: number) => {
const tx = cx + Math.cos(angle) * dx - Math.sin(angle) * dy * h
const ty = cy + faceAspect * (Math.sin(angle) * dx + Math.cos(angle) * dy * h)
const x = (tx / 512 - .5) * .76 * pose.faceScale
const y = (.5 - ty / 512) * .57 * pose.faceScale + (character.shape === 'capsule' ? -.14 : -.025) + pose.faceY
const r = shell * radiusAt(character.shape, y / shell)
return new THREE.Vector3(x, y, Math.sqrt(Math.max(.001, r * r - x * x))).applyMatrix4(matrix).project(camera)
}
const center = project(0, 0)
// Central differences follow the tangent of the curved face at this eye.
const right = project(radius * .1, 0).sub(project(-radius * .1, 0)).multiplyScalar(5).add(center)
const up = project(0, -radius * .1).sub(project(0, radius * .1)).multiplyScalar(5).add(center)
return { center, right, up }
}
/** Resting highlights are local to the face, so they remain up-left when tilted. */
function irisRestGaze(character: Pick<Character, 'iris' | 'followCursor'>, gaze: Gaze, reducedMotion = false): Gaze {
if (!character.iris) return gaze
if (reducedMotion) return { x: -.22, y: .22 }
return character.followCursor ? gaze : { x: gaze.x - .22, y: gaze.y + .22 }
}
export function resolveEyeGazes(character: Character, pose: Pose, blink: number, matrix: THREE.Matrix4, camera: THREE.Camera, gaze: Gaze, pointer?: PointerLook, layers?: FaceLayer[]): EyeGazes {
const eye = (side: number) => {
const base = localGaze({ x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, pose.eyeTilt * side + (side < 0 ? pose.leftRotation : pose.rightRotation))
if (!pointer?.weight) return base
const { center, right, up } = projectedEye(character, pose, blink, side, matrix, camera, layers)
const look = gazeAtPoint(pointer, center, right, up), weight = Math.max(0, Math.min(1, pointer.weight))
return { x: base.x + (look.x - base.x) * weight, y: base.y + (look.y - base.y) * weight }
}
return { left: eye(-1), right: eye(1) }
}
function drop(ctx: CanvasRenderingContext2D, x: number, y: number, r: number) {
ctx.beginPath(); ctx.moveTo(x, y - r * 1.5); ctx.bezierCurveTo(x - r * .25, y - r * .8, x - r, y - r * .2, x - r, y + r * .35); ctx.bezierCurveTo(x - r, y + r * 1.65, x + r, y + r * 1.65, x + r, y + r * .35); ctx.bezierCurveTo(x + r, y - r * .2, x + r * .25, y - r * .8, x, y - r * 1.5); ctx.fill()
}
export function compactMouthOffset(size: number, faceScale: number, viewHeight: number, outputHeight: number) {
return (size >= 16 && size < 32) ? 512 * viewHeight / outputHeight / (.57 * faceScale) : 0
}
export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: Character, blink: number, detail: Detail, gaze: { x: number; y: number }, effects: { phase?: number; tearAmount?: number; eyeGazes?: EyeGazes; faceLayers?: FaceLayer[]; simpleEyes?: boolean; mouthFollowsEyes?: boolean; mouthOffset?: number } = {}) {
ctx.clearRect(0, 0, 512, 512)
if (detail === 'body') return
const ink = character.eyeColor, eyeY = detail === 'eyes' ? 254 : 197, spacing = 103 * pose.spacing
const internalGaze = character.iris && detail === 'full'
const gx = internalGaze ? 0 : (pose.gazeX + gaze.x) * 20, gy = internalGaze ? 0 : -(pose.gazeY + gaze.y) * 15
const layers = effects.faceLayers
const pupilAmount = !effects.simpleEyes && detail === 'full' ? layers ? traitWeight(layers, t => t.eye === 'pupil') : pose.eye === 'pupil' ? 1 : 0 : 0
ctx.lineCap = 'round'; ctx.lineJoin = 'round'
for (const side of [-1, 1]) {
const r = eyeRadius(pose, character, detail, side)
const eyeGaze = side < 0 ? effects.eyeGazes?.left : effects.eyeGazes?.right
const pupilEyes = !effects.simpleEyes && pose.eye === 'pupil' && detail === 'full'
const x = 256 + side * spacing + gx * (1 - pupilAmount) + (side < 0 ? pose.leftX : pose.rightX), y = eyeY + gy * (1 - pupilAmount) - (side < 0 ? pose.leftY : pose.rightY)
if (detail === 'full' && pose.blush > 0) {
ctx.save(); ctx.globalAlpha = pose.blush * .8; ctx.fillStyle = '#ff647a'; ctx.beginPath(); ctx.ellipse(x + side * 20, y + 56, 31, 24 * faceAspect, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore()
}
const localRotation = side < 0 ? pose.leftRotation : pose.rightRotation
// Equal world units in X/Y: the face mesh is wider than it is tall.
ctx.save(); ctx.translate(x, y); ctx.scale(1, faceAspect); ctx.rotate((pose.eyeTilt * side + localRotation) * Math.PI / 180)
ctx.fillStyle = ink; ctx.strokeStyle = ink; ctx.lineWidth = Math.max(13, r * .43)
const happyMouth = ['open', 'grin', 'smile', 'u-smile'].includes(pose.mouth)
const closed = ['closed', 'arc-up', 'arc-down'].includes(pose.eye) || (pose.eye === 'wink' && side > 0) || (detail === 'eyes' && ['open', 'grin'].includes(pose.mouth) && pose.eye === 'dot')
if (effects.simpleEyes) {
// Keep literal round dots even at eyes-only sizes or between styled beats.
ctx.fillStyle = '#000000'; ctx.scale(1, Math.max(.12, 1 - blink))
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill()
} else if (layers) {
const look = eyeGaze ?? localGaze({ x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, pose.eyeTilt * side + localRotation)
drawMorphEye(ctx, pose, layers, r, side, blink, detail, ink, character.iris, look)
} else if (pose.faceSet === 'set-2' && pose.eye === 'wink' && side > 0) {
ctx.beginPath(); ctx.moveTo(r * .7, -r * .65); ctx.lineTo(-r * .55, 0); ctx.lineTo(r * .7, r * .65); ctx.stroke()
} else if (closed) {
const up = pose.eye === 'arc-up' || (pose.eye !== 'arc-down' && happyMouth)
ctx.beginPath(); ctx.arc(0, 0, r, up ? Math.PI : 0, up ? Math.PI * 2 : Math.PI); ctx.stroke()
} else if (pose.eye === 'squint') {
ctx.beginPath(); ctx.moveTo(-r * side * .7, -r * .8); ctx.lineTo(r * side * .6, 0); ctx.lineTo(-r * side * .7, r * .8); ctx.stroke()
} else if (blink > .7) {
ctx.beginPath(); ctx.moveTo(-r, 0); ctx.lineTo(r, 0); ctx.stroke()
} else {
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (pose.eye === 'soft' ? .65 : 1)
ctx.scale(1, h)
if (pose.cheeks) {
// The cheek is transparent, revealing the live shaded body beneath the eye.
ctx.beginPath(); ctx.rect(-r * 2, -r * 2, r * 4, r * 4); ctx.moveTo(r * .82, r * 1.13); ctx.arc(0, r * 1.13, r * .82, 0, Math.PI * 2); ctx.clip('evenodd')
}
if (pose.eye === 'star') star(ctx, 0, 0, r * 1.2)
else if (pose.eye === 'heart') { if (pose.faceSet === 'set-2' && detail === 'full') ctx.fillStyle = '#ff3f58'; heart(ctx, 0, 0, r) }
else if (pose.eye === 'half-lidded') { ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI); ctx.closePath(); ctx.fill() }
else if (pupilEyes) {
const outer = r * 1.5
ctx.fillStyle = '#fffef9'; ctx.beginPath(); ctx.arc(0, 0, outer, 0, Math.PI * 2); ctx.fill(); ctx.clip()
const look = eyeGaze ?? { x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }
ctx.fillStyle = ink; ctx.beginPath(); ctx.arc(look.x * outer * .52, -look.y * outer * .52, outer * .4, 0, Math.PI * 2); ctx.fill()
}
else { ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill() }
if (internalGaze && !pupilEyes && pose.eye !== 'half-lidded') {
// The eye path also clips star/heart shapes and intersects cheek cutouts.
ctx.clip()
const dot = irisOffset(r, eyeGaze ?? { x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, eyeGaze ? 0 : pose.eyeTilt * side + localRotation, pose.cheeks)
ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(dot.x, dot.y, r * .28, 0, Math.PI * 2); ctx.fill()
}
}
ctx.restore()
if (detail === 'full' && (layers || pose.brows !== 'none')) {
ctx.save(); ctx.translate(x, y - r * (1.45 + .35 * pupilAmount)); ctx.scale(1, faceAspect)
drawMorphBrow(ctx, layers ?? faceLayers(pose), r, side, ink, pose.browStroke ?? 1, pose.browLength ?? 1); ctx.restore()
}
if (pose.tears && detail === 'full') {
const phase = fract((effects.phase ?? .35) + (side < 0 ? .46 : 0)), alpha = effectEnvelope(phase) * (effects.tearAmount ?? 1)
ctx.save(); ctx.translate(x + side * (r + 18), y + 23 + phase * phase * 123); ctx.scale(1, faceAspect); ctx.rotate(-side * .2); ctx.globalAlpha = alpha; ctx.fillStyle = '#f6fdff'; drop(ctx, 0, 0, 12 + phase * 3); ctx.restore()
}
}
if (detail === 'eyes') return
const mouthX = effects.mouthFollowsEyes ? 256 + gx * (1 - pupilAmount) + (pose.leftX + pose.rightX) / 2 : 256 + gx * .25
const mouthY = (effects.mouthFollowsEyes ? 275 + gy : 293 + gy * .25) + (effects.mouthOffset ?? 0)
if (layers) {
ctx.save(); ctx.translate(mouthX, mouthY); ctx.scale(1, faceAspect)
drawMorphMouth(ctx, pose, layers, ink); ctx.restore(); return
}
const x = mouthX, y = pose.mouth === 'cry' ? 329 + gy * .25 : mouthY
const broad = ['open', 'grin', 'cry'].includes(pose.mouth), w = (broad ? 124 : pose.mouth === 'oh' ? 51 : 61) * pose.mouthWidth
const line = 17 * pose.mouthStroke
ctx.save(); ctx.translate(x, y); ctx.scale(1, faceAspect); ctx.fillStyle = ink; ctx.strokeStyle = ink; ctx.lineWidth = line; ctx.beginPath()
if (pose.mouth === 'smile') { ctx.arc(0, -10, w, Math.PI * .18, Math.PI * .82); ctx.stroke() }
else if (pose.mouth === 'u-smile') { ctx.arc(0, -8, w * .56, 0, Math.PI); ctx.stroke() }
else if (pose.mouth === 'frown') { ctx.arc(0, 39, w, Math.PI * 1.2, Math.PI * 1.8); ctx.stroke() }
else if (pose.mouth === 'line' || pose.mouth === 'sleep') { ctx.moveTo(-w * .58, 0); ctx.lineTo(w * .58, 0); ctx.stroke() }
else if (pose.mouth === 'kiss') { ctx.moveTo(-w * .25, -21); ctx.bezierCurveTo(w * .48, -34, w * .53, -2, 0, 0); ctx.bezierCurveTo(w * .53, 2, w * .48, 34, -w * .25, 21); ctx.stroke() }
else if (pose.mouth === 'tongue-out') {
ctx.beginPath(); ctx.moveTo(-w, -9); ctx.quadraticCurveTo(0, -2, w, -9); ctx.stroke()
ctx.fillStyle = '#ff526c'; ctx.beginPath(); ctx.moveTo(-w * .6, 5); ctx.lineTo(w * .6, 5); ctx.bezierCurveTo(w * .88, 85 * pose.mouthOpen + 28, -w * .88, 85 * pose.mouthOpen + 28, -w * .6, 5); ctx.fill()
}
else if (pose.mouth === 'wave') { ctx.moveTo(-w, 4); ctx.bezierCurveTo(-w * .35, -25, w * .35, 25, w, -4); ctx.stroke() }
else {
const h = (broad ? 48 : 22) + 72 * pose.mouthOpen
if (pose.mouth === 'oh') ctx.arc(0, 14, w * (.55 + .3 * pose.mouthOpen), 0, Math.PI * 2)
else if (pose.mouth === 'cry') { ctx.moveTo(-w, 30); ctx.bezierCurveTo(-w * 1.1, -h, w * 1.1, -h, w, 30); ctx.quadraticCurveTo(w, 44, w * .76, 38); ctx.quadraticCurveTo(0, 24, -w * .76, 38); ctx.quadraticCurveTo(-w, 44, -w, 30) }
else { const tilt = pose.mouth === 'grin' ? 24 : 0; ctx.moveTo(-w, -11); ctx.quadraticCurveTo(0, 5, w, -11 - tilt); ctx.bezierCurveTo(w * 1.02, h, -w * 1.02, h, -w, -11) }
ctx.closePath(); ctx.fill(); ctx.save(); ctx.clip()
if (pose.tongue) { ctx.fillStyle = '#f37b83'; ctx.beginPath(); ctx.ellipse(10, h * .65, w * .65, h * .34, -.1, 0, Math.PI * 2); ctx.fill() }
if (pose.teeth) { ctx.fillStyle = '#fffef8'; ctx.beginPath(); ctx.roundRect(-w * .76, -23, w * 1.52, 30, 12); ctx.fill() }
ctx.restore()
// Rebuild the outer path after the clipped interior details changed the canvas path.
ctx.beginPath()
if (pose.mouth === 'oh') ctx.arc(0, 14, w * (.55 + .3 * pose.mouthOpen), 0, Math.PI * 2)
else if (pose.mouth === 'cry') { ctx.moveTo(-w, 30); ctx.bezierCurveTo(-w * 1.1, -h, w * 1.1, -h, w, 30); ctx.quadraticCurveTo(w, 44, w * .76, 38); ctx.quadraticCurveTo(0, 24, -w * .76, 38); ctx.quadraticCurveTo(-w, 44, -w, 30) }
else { const tilt = pose.mouth === 'grin' ? 24 : 0; ctx.moveTo(-w, -11); ctx.quadraticCurveTo(0, 5, w, -11 - tilt); ctx.bezierCurveTo(w * 1.02, h, -w * 1.02, h, -w, -11) }
ctx.closePath(); ctx.strokeStyle = ink; ctx.stroke()
}
if (pose.drool) {
const anchor = droolAnchor(mouthGeometry(pose).outline, w)
if (pose.mouth === 'cry') anchor.y -= 27
drawDrool(ctx, anchor)
}
ctx.restore()
}
export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], color: string, phase?: number, settings: Partial<ParticleSettings> = {}) {
ctx.clearRect(0, 0, 256, 256)
const layout = particleLayout(prop, phase, settings)
ctx.save(); ctx.translate(128, 128); ctx.scale(256 / layout.extent, 256 / layout.extent); ctx.translate(-128, -128)
for (const particle of layout.particles) {
const i = particle.index
ctx.save(); ctx.globalAlpha = particle.alpha
ctx.translate(particle.x, particle.y); ctx.scale(particle.scale, particle.scale)
ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 11; ctx.lineCap = 'round'; ctx.lineJoin = 'round'
if (prop === 'zzz') { const r = 14 + i * 5; ctx.beginPath(); ctx.moveTo(-r, -r); ctx.lineTo(r, -r); ctx.lineTo(-r, r); ctx.lineTo(r, r); ctx.stroke() }
else if (prop === 'sparkle') star(ctx, 0, 0, 23 + i * 4, 4)
else if (prop === 'heart') heart(ctx, 0, 0, 17 + i * 3)
else if (prop === 'question') { ctx.font = 'bold 144px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('?', 0, 48) }
else if (prop === 'sweat') drop(ctx, 0, 0, 38)
else if (prop === 'crown') { ctx.beginPath(); ctx.moveTo(-78, 42); ctx.lineTo(-94, -45); ctx.lineTo(-35, -3); ctx.lineTo(0, -67); ctx.lineTo(35, -3); ctx.lineTo(94, -45); ctx.lineTo(78, 42); ctx.closePath(); ctx.fill() }
ctx.restore()
}
ctx.restore()
}
export class CharacterRenderer {
readonly canvas: HTMLCanvasElement | null
readonly gl: THREE.WebGLRenderer | null
private scene = new THREE.Scene()
private shadowScene = new THREE.Scene()
private camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 30)
private root = new THREE.Group()
private body: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>
private lightFill: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>
private face: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>
private faceCanvas: HTMLCanvasElement | null = null
private faceCtx: CanvasRenderingContext2D | null = null
private faceTexture: THREE.CanvasTexture | null = null
private propCanvas: HTMLCanvasElement | null = null
private propCtx: CanvasRenderingContext2D | null = null
private propTexture: THREE.CanvasTexture | null = null
private prop: THREE.Sprite
private shadow: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>
private shape: Shape = 'capsule'
private squareBottom = false
private lastFaceKey = ''
private resolvedEyes: EyeGazes | undefined
private lastProp = ''
private disposed = false
private options: RenderOptions
private captured?: { character: Character; sample: Sample; gaze: Gaze; simpleEyes: boolean }
constructor(canvas: HTMLCanvasElement | null, options: RenderOptions) {
this.canvas = canvas; this.options = options
this.gl = null
if (canvas) {
this.faceCanvas = document.createElement('canvas')
this.propCanvas = document.createElement('canvas')
this.gl = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true, powerPreference: 'low-power' })
this.gl.outputColorSpace = THREE.SRGBColorSpace
this.gl.setPixelRatio(options.pixelRatio ?? Math.min(Math.max(window.devicePixelRatio || 1, 1) * 2, 4))
this.faceCanvas.width = this.faceCanvas.height = 1024
this.faceCtx = this.faceCanvas.getContext('2d')!
this.faceCtx.scale(2, 2)
this.faceTexture = new THREE.CanvasTexture(this.faceCanvas); this.faceTexture.colorSpace = THREE.SRGBColorSpace
this.faceTexture.generateMipmaps = true; this.faceTexture.minFilter = THREE.LinearMipmapLinearFilter
this.propCanvas.width = this.propCanvas.height = 512; this.propCtx = this.propCanvas.getContext('2d')!; this.propCtx.scale(2, 2)
this.propTexture = new THREE.CanvasTexture(this.propCanvas); this.propTexture.colorSpace = THREE.SRGBColorSpace
}
const material = new THREE.ShaderMaterial({
uniforms: { colorA: { value: new THREE.Color() }, colorB: { value: new THREE.Color() }, gradientOn: { value: 1 }, toonOn: { value: 1 }, insetFill: { value: 0 }, fillScale: { value: new THREE.Vector3(1, 1, 1) }, fillOffset: { value: new THREE.Vector3() }, angle: { value: 0 }, bodyHeight: { value: 2 } }, vertexShader, fragmentShader
})
this.body = new THREE.Mesh(geometryFor('capsule'), material)
const fillMaterial = material.clone(); fillMaterial.depthTest = false; fillMaterial.depthWrite = false; fillMaterial.uniforms.insetFill!.value = 1
this.lightFill = new THREE.Mesh(new THREE.CapsuleGeometry(.42, .94, 24, 80), fillMaterial); this.lightFill.renderOrder = 1
this.face = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 56, 40), new THREE.MeshBasicMaterial({ map: this.faceTexture, transparent: true, alphaTest: .008, depthWrite: false, side: THREE.FrontSide, toneMapped: false }))
this.face.geometry.setAttribute('faceValid', new THREE.BufferAttribute(new Float32Array(this.face.geometry.attributes.position!.count).fill(1), 1))
this.face.material.onBeforeCompile = shader => {
shader.vertexShader = 'attribute float faceValid; varying float vFaceValid;\n' + shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvFaceValid = faceValid;')
shader.fragmentShader = 'varying float vFaceValid;\n' + shader.fragmentShader.replace('void main() {', 'void main() {\nif (vFaceValid < 0.99) discard;')
}
this.prop = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.propTexture, transparent: true, depthWrite: false, toneMapped: false }))
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(.35, 64), new THREE.MeshBasicMaterial({ color: '#809299', transparent: true, opacity: .2, depthWrite: false }))
this.root.add(this.body, this.lightFill, this.face, this.prop); this.scene.add(this.root); this.shadowScene.add(this.shadow)
this.camera.position.set(0, 0, 8); this.camera.lookAt(0, 0, 0)
this.resize(options.width, options.height, options.displaySize)
}
resize(width: number, height: number, displaySize?: number) {
this.options = { ...this.options, width: Math.max(1, width), height: Math.max(1, height), displaySize: displaySize ?? Math.min(width, height) }
this.gl?.setSize(this.options.width, this.options.height, false)
}
render(character: Character, sample: Sample, options: Partial<RenderOptions> = {}, gaze = { x: 0, y: 0 }) {
if (this.disposed) return
this.options = { ...this.options, ...options }
const displaySize = this.options.displaySize ?? Math.min(this.options.width, this.options.height)
const appearance = faceForSize(character, sample, displaySize)
character = appearance.character; sample = appearance.sample
const squareBottom = character.shape === 'cap' && displaySize <= 24
if (character.shape !== this.shape || squareBottom !== this.squareBottom) { this.shape = character.shape; this.squareBottom = squareBottom; this.body.geometry.dispose(); this.body.geometry = geometryFor(this.shape, squareBottom); this.lightFill.geometry.dispose(); this.lightFill.geometry = this.shape === 'capsule' ? new THREE.CapsuleGeometry(.42, .94, 24, 80) : geometryFor(this.shape) }
const { pose } = sample
const height = bodyHeight(this.shape)
const detail = detailAt(displaySize)
const u = this.body.material.uniforms
;(u.colorA!.value as THREE.Color).set(character.color); (u.colorB!.value as THREE.Color).set(character.color2)
u.gradientOn!.value = character.gradient ? 1 : (sample.gradientMix ?? (sample.gradientRotation !== undefined ? 1 : 0)); u.toonOn!.value = character.toon ? 1 : 0
u.angle!.value = (character.gradientAngle + (sample.gradientRotation ?? 0)) * Math.PI / 180; u.bodyHeight!.value = height
const rotation = characterRotation(this.options.reducedMotion ? { ...character, followRotation: false } : character, pose, this.options.rotation, this.options.cursor)
this.root.rotation.set(rotation.x * Math.PI / 180, rotation.y * Math.PI / 180, rotation.z * Math.PI / 180, 'YXZ')
this.root.position.y = character.lockPosition ? 0 : sample.bob * .025 * height
const stretch = pose.squash + (character.lockPosition ? 0 : sample.breathe * .008)
this.root.scale.set(1 / Math.sqrt(stretch), stretch, 1 / Math.sqrt(stretch))
this.lightFill.visible = character.toon
const fillScale = this.shape === 'capsule' ? 1 : .85
this.lightFill.scale.setScalar(fillScale)
this.root.updateMatrixWorld(true)
// Shift the inset toward the face marker in the camera plane, not the tilted
// body's axes. Bound travel by the narrowest stretch to retain a dark rim.
const lightTarget = toonLightOffset(this.root.quaternion)
const lightScale = Math.min(this.root.scale.x, this.root.scale.y, this.root.scale.z)
const lightOffset = new THREE.Vector3(lightTarget.x * lightScale, lightTarget.y * lightScale, 0).applyMatrix4(new THREE.Matrix4().copy(this.root.matrixWorld).invert())
.sub(new THREE.Vector3().applyMatrix4(new THREE.Matrix4().copy(this.root.matrixWorld).invert()))
this.lightFill.position.copy(lightOffset)
const fu = this.lightFill.material.uniforms
for (const name of ['colorA', 'colorB']) (fu[name]!.value as THREE.Color).copy(u[name]!.value as THREE.Color)
for (const name of ['gradientOn', 'toonOn', 'angle', 'bodyHeight']) fu[name]!.value = u[name]!.value
;(fu.fillScale!.value as THREE.Vector3).setScalar(fillScale); (fu.fillOffset!.value as THREE.Vector3).copy(lightOffset)
const zoom = displaySize <= 128 ? 1 : this.options.zoom ?? 1
const aspect = this.options.width / this.options.height
// Small assets use a tight body fit instead of the companion preview padding.
const padding = this.options.framing === 'character' ? .82 : displaySize <= 128 ? .55 : .72
let half = Math.max(height * padding, padding / aspect) / zoom
if (displaySize > 32 && displaySize <= 128) {
// A sphere bounds every rotation, avoiding angle-dependent zoom changes.
if (!this.body.geometry.boundingSphere) this.body.geometry.computeBoundingSphere()
const sphere = this.body.geometry.boundingSphere!
const radius = (sphere.radius + sphere.center.length()) * Math.max(1, this.root.scale.x, this.root.scale.y, this.root.scale.z)
const safeHalf = (radius + this.root.position.length()) / .9
half = Math.max(half, safeHalf, safeHalf / aspect)
}
this.camera.left = -half * aspect; this.camera.right = half * aspect; this.camera.top = half; this.camera.bottom = -half; this.camera.updateProjectionMatrix()
this.camera.updateMatrixWorld(true)
this.face.visible = detail !== 'body'
// Pointer directions stay relative to the screen when the character rolls or turns.
const reduced = !!this.options.reducedMotion
const screenGaze = reduced ? { x: 0, y: 0 } : gaze
const turnGaze = !reduced && !character.followCursor && character.followRotation && !character.trueFront ? this.options.cursor ?? { x: 0, y: 0 } : { x: 0, y: 0 }
const local = character.iris ? new THREE.Vector3(screenGaze.x + turnGaze.x * .575, screenGaze.y + turnGaze.y * .575, 0).applyQuaternion(this.root.quaternion.clone().invert()) : screenGaze
const faceGaze = displaySize <= 32 ? { x: 0, y: 0 } : irisRestGaze(character, local, reduced)
this.resolvedEyes = !appearance.simpleEyes && detail === 'full' && (character.iris || pose.eye === 'pupil' || sample.faceLayers?.some(l => l.traits.eye === 'pupil')) ? (!reduced ? this.options.eyeGazes : undefined) ?? resolveEyeGazes(character, reduced ? { ...pose, gazeX: 0, gazeY: 0 } : pose, sample.blink, this.root.matrixWorld, this.camera, faceGaze, !reduced && character.followCursor ? this.options.pointerLook : undefined, sample.faceLayers) : undefined
const mouthOffset = compactMouthOffset(displaySize, pose.faceScale, half * 2, displaySize)
const key = JSON.stringify([mouthOffset, pose, sample.faceLayers, character.eyeColor, character.iris, appearance.simpleEyes, sample.blink.toFixed(3), pose.tears ? sample.effectPhase?.toFixed(2) : 0, sample.tearAmount, detail, faceGaze.x.toFixed(3), faceGaze.y.toFixed(3), this.resolvedEyes])
if (this.faceCtx && this.faceTexture && key !== this.lastFaceKey) { drawFace(this.faceCtx, pose, character, sample.blink, detail, faceGaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: this.resolvedEyes, faceLayers: sample.faceLayers, simpleEyes: appearance.simpleEyes, mouthOffset, mouthFollowsEyes: displaySize >= 16 && displaySize <= 32 }); this.faceTexture.needsUpdate = true; this.lastFaceKey = key }
const vertices = this.face.geometry.attributes.position!
const uv = this.face.geometry.attributes.uv!
const valid = this.face.geometry.attributes.faceValid!
const shell = character.elevated ? 1 + character.elevation : 1.003
const scale = pose.faceScale
const offset = (this.shape === 'capsule' ? -.14 : -.025) + pose.faceY
for (let i = 0; i < vertices.count; i++) {
const x = (uv.getX(i) - .5) * .76 * scale
const y = (uv.getY(i) - .5) * .57 * scale + offset
const r = shell * radiusAt(this.shape, y / shell)
const z = Math.sqrt(Math.max(.001, r * r - x * x))
vertices.setXYZ(i, x, y, z)
valid.setX(i, x * x < r * r && Math.abs(y) < height / 2 * shell ? 1 : 0)
}
vertices.needsUpdate = true; valid.needsUpdate = true; this.face.geometry.computeBoundingSphere()
this.prop.visible = detail === 'full' && pose.prop !== 'none'
this.prop.material.depthTest = pose.prop !== 'zzz'
this.prop.renderOrder = pose.prop === 'zzz' ? 2 : 0
const propKey = `${pose.prop}:${pose.propSize}:${pose.propCount}:${pose.propOutward}:${sample.effectPhase?.toFixed(2) ?? 'still'}`
if (this.propCtx && this.propTexture && propKey !== this.lastProp) { drawProp(this.propCtx, pose.prop, pose.prop === 'heart' ? '#ff768c' : pose.prop === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase, pose); this.propTexture.needsUpdate = true; this.lastProp = propKey }
const propSize = this.shape === 'capsule' ? .42 : .32
this.prop.scale.setScalar(propSize * particleLayout(pose.prop, sample.effectPhase, pose).extent / 256 * (.7 + .3 * (sample.propAmount ?? 1))); this.prop.material.opacity = sample.propAmount ?? 1
this.prop.position.set(pose.prop === 'crown' ? 0 : .56, pose.prop === 'crown' ? height / 2 + .08 : height * .29, .15)
this.shadow.visible = character.shadow && detail === 'full'
this.shadow.position.set(0, -height * .58, -.2)
this.shadow.scale.set((1 - (character.lockPosition ? 0 : sample.bob) * .06) * (this.shape === 'capsule' ? 1 : .95), .12, 1)
const bg = this.options.background
if (bg) this.gl?.setClearColor(bg, 1); else this.gl?.setClearColor(0x000000, 0)
// World matrices are updated explicitly: without a GL render (the Node
// SVG snapshot path) nothing else would, and snapshotScene reads them.
this.shadowScene.updateMatrixWorld(true); this.scene.updateMatrixWorld(true)
// Composite the ground shadow first: body rotation must never bring it
// in front of the silhouette or the toon fill, which does not write depth.
if (this.gl) {
this.gl.autoClear = true
this.gl.render(this.shadowScene, this.camera)
this.gl.autoClear = false
this.gl.clearDepth()
this.gl.render(this.scene, this.camera)
this.gl.autoClear = true
}
this.captured = { character, sample, gaze: faceGaze, simpleEyes: appearance.simpleEyes }
}
snapshotScene() {
if (!this.captured) throw new Error('Render the character before taking a snapshot.')
return { ...this.captured, options: this.options, camera: this.camera, body: this.body, lightFill: this.lightFill, face: this.face, prop: this.prop, shadow: this.shadow, eyeGazes: this.resolvedEyes }
}
orientation() { return this.root.quaternion.clone() }
eyeGazes() { return this.resolvedEyes ? { left: { ...this.resolvedEyes.left }, right: { ...this.resolvedEyes.right } } : undefined }
dispose() {
if (this.disposed) return
this.disposed = true
this.body.geometry.dispose(); this.body.material.dispose(); this.lightFill.geometry.dispose(); this.lightFill.material.dispose(); this.face.geometry.dispose(); this.face.material.dispose()
this.faceTexture?.dispose(); this.propTexture?.dispose(); this.prop.material.dispose(); this.shadow.geometry.dispose(); this.shadow.material.dispose()
this.gl?.dispose(); this.gl?.forceContextLoss()
}
}
let thumbRenderer: CharacterRenderer | undefined
const thumbs = new Map<string, string>()
export function thumbnail(character: Character, pose: Pose = BASE_POSE, size = 96): string {
const key = JSON.stringify([character, pose, size])
const cached = thumbs.get(key); if (cached) return cached
try {
if (!thumbRenderer) thumbRenderer = new CharacterRenderer(document.createElement('canvas'), { width: 192, height: 192, pixelRatio: 1, displaySize: size })
thumbRenderer.render({ ...character, shadow: false }, { pose, blink: 0, bob: 0, breathe: 0, expressionId: '', beatIndex: 0, stepIndex: 0 }, { displaySize: size, rotation: { x: -3, y: -8, z: -5 }, zoom: 1.08 })
const url = thumbRenderer.canvas!.toDataURL('image/png'); if (thumbs.size > 250) thumbs.clear(); thumbs.set(key, url); return url
} catch { return '' }
}