mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## 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>
483 lines
36 KiB
TypeScript
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 '' }
|
|
}
|