@gtanczyk/diorama 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +289 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +142 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +142 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +36 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +192 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +90 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +91 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +31 -0
- package/app/src/scene/cast.ts +192 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +55 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +417 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +554 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +421 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +677 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1328 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3751 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
// A rigged 3D figure (cast/<key>/model/) drawn into its own canvas, so the 2D page draws it like a
|
|
2
|
+
// sprite: feet planted on the walk cycle the 2D rig uses, any facing, Muse's four painted views on its
|
|
3
|
+
// surface, lit by the painting around it. The page only imports this when a world ships a model.
|
|
4
|
+
import * as THREE from 'three';
|
|
5
|
+
import { ModelFace } from './face3d.ts';
|
|
6
|
+
import type { FaceDefinition, FaceState } from '../src/play/lipsync.ts';
|
|
7
|
+
import { PaintedFace } from './face.ts';
|
|
8
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
9
|
+
import { GESTURES, type Pose as GesturePose } from '../src/play/gestures.ts';
|
|
10
|
+
import { CYCLE } from '../src/play/rig.ts';
|
|
11
|
+
import { creatureMotion } from '../src/play/creature.ts';
|
|
12
|
+
import { shapeProblem } from '../src/model/quality.ts';
|
|
13
|
+
|
|
14
|
+
const STANCE = 0.6;
|
|
15
|
+
|
|
16
|
+
const LIFT = 0.06;
|
|
17
|
+
const smooth = (u: number) => u * u * (3 - 2 * u);
|
|
18
|
+
|
|
19
|
+
/** model.json, written by `diorama model`. */
|
|
20
|
+
export type ModelInfo = {
|
|
21
|
+
glb: string;
|
|
22
|
+
/** Use the GLB's UV texture when independently painted views project duplicate features onto the mesh. Omitted: projected views. */
|
|
23
|
+
texture?: 'model' | 'views';
|
|
24
|
+
/** Disable the long-coat weight correction for trousers. Omitted preserves legacy models. */
|
|
25
|
+
coatCorrection?: boolean;
|
|
26
|
+
/** With the UV texture: 'views' when TRELLIS baked the face blank, so the front painting goes on the face (src/model/sprites.ts uvFaceLost). */
|
|
27
|
+
face?: 'views';
|
|
28
|
+
/** Not a person on two legs (a round eye, a frog): the model has no skeleton and moves as a whole body (src/play/creature.ts). */
|
|
29
|
+
creature?: boolean;
|
|
30
|
+
/** Blender put the rest pose calm (src/model/blender.ts): arms hang, so they may swing like a walker's. */
|
|
31
|
+
relaxed?: boolean;
|
|
32
|
+
/** bands: the across fit per slice of the height (src/model/fit.ts BANDS), feet first; without them, u everywhere. */
|
|
33
|
+
views: Record<'front' | 'back' | 'left' | 'right', { image: string; u: [number, number]; v: [number, number]; iou: number; bands?: [number, number][] }>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** How the painting lights the figure where it stands (colours as 0xRRGGBB). */
|
|
37
|
+
export type FigureLight = {
|
|
38
|
+
sky: number; ground: number; ambient: number;
|
|
39
|
+
key?: { dir: [number, number, number]; color: number; intensity: number };
|
|
40
|
+
rim?: { dir: [number, number, number]; color: number; intensity: number };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default light for a painted model: the textures are the painted sides, shading already in them, so the light
|
|
45
|
+
* gives back their own brightness. three.js lights are physical (a white surface under a white light of
|
|
46
|
+
* intensity 1 shows 1/PI), so the hemisphere is PI times as strong: a wall-facing surface (half sky, half
|
|
47
|
+
* ground) comes out at 1.0 of its paint, a top at 1.12, an underside at 0.88, enough to keep the form readable.
|
|
48
|
+
* The old light (white over 0x888888 at 2.2) gave a side 0.44 of its paint in linear light: models looked
|
|
49
|
+
* a third darker than the four painted sides, and so did the sprite guides Muse painted over.
|
|
50
|
+
*/
|
|
51
|
+
export const PAINTED_LIGHT: FigureLight = { sky: 0xffffff, ground: 0xe5e5e5, ambient: 1.12 * Math.PI };
|
|
52
|
+
|
|
53
|
+
/** Mean colour (linear) of a texture's used texels: the black or transparent spare atlas space is left out. */
|
|
54
|
+
function meanColour(image: CanvasImageSource): [number, number, number] | undefined {
|
|
55
|
+
const R = 128;
|
|
56
|
+
const c = Object.assign(document.createElement('canvas'), { width: R, height: R }).getContext('2d', { willReadFrequently: true })!;
|
|
57
|
+
c.drawImage(image, 0, 0, R, R);
|
|
58
|
+
const px = c.getImageData(0, 0, R, R).data;
|
|
59
|
+
const sum = [0, 0, 0, 0];
|
|
60
|
+
for (let at = 0; at < px.length; at += 4) {
|
|
61
|
+
if (px[at + 3] < 128 || px[at] + px[at + 1] + px[at + 2] < 6) continue;
|
|
62
|
+
for (let ch = 0; ch < 3; ch++) sum[ch] += (px[at + ch] / 255) ** 2.2;
|
|
63
|
+
sum[3]++;
|
|
64
|
+
}
|
|
65
|
+
return sum[3] ? [sum[0] / sum[3], sum[1] / sum[3], sum[2] / sum[3]] : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The UV texture's colours brought to the paintings' (linear light). TRELLIS bakes its texture pale, grey and
|
|
70
|
+
* soft: a teal paper coat came out powder blue, and the front painting laid on the face showed as a lighter
|
|
71
|
+
* patch. Every surface point a painted view sees head-on gives a pair (texture colour, painting colour); each
|
|
72
|
+
* channel's mean and spread over those points is matched, so the whole texture takes the paintings' hues,
|
|
73
|
+
* saturation and contrast, and the painted face sits in the same tones. Undefined with too few pairs.
|
|
74
|
+
*/
|
|
75
|
+
function matchColours(texture: CanvasImageSource, uv: THREE.BufferAttribute | undefined, rest: Float32Array, restN: Float32Array, vis: THREE.BufferAttribute,
|
|
76
|
+
views: Record<string, { image: HTMLImageElement; fit: { u: [number, number]; v: [number, number]; bands?: Array<[number, number]> }; bg: number[] }>) {
|
|
77
|
+
if (!uv) return undefined;
|
|
78
|
+
const read = (image: CanvasImageSource, w: number, h: number) => {
|
|
79
|
+
const c = Object.assign(document.createElement('canvas'), { width: w, height: h }).getContext('2d', { willReadFrequently: true })!;
|
|
80
|
+
c.drawImage(image, 0, 0, w, h);
|
|
81
|
+
return c.getImageData(0, 0, w, h).data;
|
|
82
|
+
};
|
|
83
|
+
const T = 512, tex = read(texture, T, T);
|
|
84
|
+
const lin = (v: number) => (v / 255) ** 2.2;
|
|
85
|
+
const X: number[][] = [], Y: number[][] = [];
|
|
86
|
+
VIEWS.forEach(([name, ax, sx, nax, ns], k) => {
|
|
87
|
+
const view = views[name];
|
|
88
|
+
if (!view) return;
|
|
89
|
+
const W = 256, H = 256, px = read(view.image, W, H);
|
|
90
|
+
const [a, n] = [{ x: 0, y: 1, z: 2 }[ax], { x: 0, y: 1, z: 2 }[nax]];
|
|
91
|
+
const bands = view.fit.bands?.length === BANDS ? view.fit.bands : Array(BANDS).fill(view.fit.u);
|
|
92
|
+
for (let i = 0; i < uv.count; i += 2) {
|
|
93
|
+
if (vis.getComponent(i, k) < 0.9 || ns * restN[i * 3 + n] < 0.7) continue;
|
|
94
|
+
const y = rest[i * 3 + 1];
|
|
95
|
+
const at = Math.min(BANDS - 1, Math.max(0, y * BANDS - 0.5)), b0 = Math.floor(at), b1 = Math.min(b0 + 1, BANDS - 1), t = at - b0;
|
|
96
|
+
const ab = [0, 1].map((j) => bands[b0][j] * (1 - t) + bands[b1][j] * t);
|
|
97
|
+
const vx = Math.floor((ab[0] * sx * rest[i * 3 + a] + ab[1]) * W), vy = Math.floor((view.fit.v[0] * y + view.fit.v[1]) * H);
|
|
98
|
+
if (vx < 0 || vy < 0 || vx >= W || vy >= H) continue;
|
|
99
|
+
const p = (vy * W + vx) * 4;
|
|
100
|
+
if (Math.abs(px[p] - view.bg[0]) + Math.abs(px[p + 1] - view.bg[1]) + Math.abs(px[p + 2] - view.bg[2]) < 60) continue;
|
|
101
|
+
const tx = Math.floor((((uv.getX(i) % 1) + 1) % 1) * T), ty = Math.floor((((uv.getY(i) % 1) + 1) % 1) * T), q = (ty * T + tx) * 4;
|
|
102
|
+
if (tex[q + 3] < 128) continue;
|
|
103
|
+
X.push([lin(tex[q]), lin(tex[q + 1]), lin(tex[q + 2]), 1]);
|
|
104
|
+
Y.push([lin(px[p]), lin(px[p + 1]), lin(px[p + 2])]);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
if (X.length < 300) return undefined;
|
|
108
|
+
// Moments, not least squares: noisy pairs (a fit a few pixels off at an edge) make a regression shrink every
|
|
109
|
+
// slope, a greyer result. Matching each channel's mean and spread over the same surface keeps the contrast.
|
|
110
|
+
const moments = (rows: number[][], ch: number) => {
|
|
111
|
+
const mean = rows.reduce((s, r) => s + r[ch], 0) / rows.length;
|
|
112
|
+
return [mean, Math.sqrt(rows.reduce((s, r) => s + (r[ch] - mean) ** 2, 0) / rows.length)];
|
|
113
|
+
};
|
|
114
|
+
const gain = [0, 1, 2].map((ch) => { const [mu, su] = moments(X, ch), [mp, sp] = moments(Y, ch); const k = Math.min(2.5, Math.max(0.5, sp / Math.max(1e-4, su))); return [k, mp - k * mu]; });
|
|
115
|
+
const m = new THREE.Matrix3().set(gain[0][0], 0, 0, 0, gain[1][0], 0, 0, 0, gain[2][0]);
|
|
116
|
+
return { m, o: new THREE.Vector3(gain[0][1], gain[1][1], gain[2][1]), pairs: X.length };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function footAt(p: number, step: number) {
|
|
120
|
+
p = ((p % 1) + 1) % 1;
|
|
121
|
+
if (p < STANCE) return { dx: step / 2 - (step * p) / STANCE, lift: 0, stance: true, u: p / STANCE };
|
|
122
|
+
const u = (p - STANCE) / (1 - STANCE);
|
|
123
|
+
return { dx: -step / 2 + step * smooth(u), lift: Math.sin(Math.PI * u), stance: false, u };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Extra foot rotation during swing; the early toe-off bend fades in and out without snapping. */
|
|
127
|
+
export function swingFootPitch(u: number): number {
|
|
128
|
+
const toeOff = Math.sin(Math.PI * Math.min(1, Math.max(0, u / 0.3))) ** 2;
|
|
129
|
+
return 0.35 * Math.sin(Math.PI * u) - 0.25 * toeOff;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const X = new THREE.Vector3(1, 0, 0);
|
|
133
|
+
const Y = new THREE.Vector3(0, 1, 0);
|
|
134
|
+
const Z = new THREE.Vector3(0, 0, 1);
|
|
135
|
+
const turn = (axis: THREE.Vector3, a: number) => new THREE.Quaternion().setFromAxisAngle(axis, a);
|
|
136
|
+
|
|
137
|
+
// Which way each painted view looks at the figure (it faces +z): model axis across the picture, its
|
|
138
|
+
// sign, and the normal axis and sign of the surfaces that view sees. Matches src/model/fit.ts.
|
|
139
|
+
const VIEWS = [['front', 'x', 1, 'z', 1], ['back', 'x', -1, 'z', -1], ['left', 'z', -1, 'x', 1], ['right', 'z', 1, 'x', -1]] as const;
|
|
140
|
+
|
|
141
|
+
/** Slices of the height each view is fitted on, as src/model/fit.ts BANDS (no Node imports here). */
|
|
142
|
+
const BANDS = 20;
|
|
143
|
+
|
|
144
|
+
/** Height (share of the figure, feet 0) above which a surface counts as head: one view paints all of it. */
|
|
145
|
+
const HEAD_FROM = 0.8;
|
|
146
|
+
|
|
147
|
+
/** How much each view is preferred where two see a surface (added to how squarely it faces it). */
|
|
148
|
+
const TRUST = { front: 0.3, back: 0.1, left: 0, right: 0 };
|
|
149
|
+
|
|
150
|
+
// Kuwahara: flattens 3D shading into paint-like patches.
|
|
151
|
+
const KUWAHARA = `
|
|
152
|
+
uniform sampler2D tex; uniform vec2 texel; uniform int radius; varying vec2 vUv;
|
|
153
|
+
void main() {
|
|
154
|
+
vec4 c0 = texture2D(tex, vUv);
|
|
155
|
+
if (radius == 0) {
|
|
156
|
+
gl_FragColor = c0;
|
|
157
|
+
#include <colorspace_fragment>
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
vec3 m[4]; vec3 s[4]; float a[4];
|
|
161
|
+
for (int k = 0; k < 4; k++) { m[k] = vec3(0.); s[k] = vec3(0.); a[k] = 0.; }
|
|
162
|
+
for (int j = -4; j <= 4; j++) for (int i = -4; i <= 4; i++) {
|
|
163
|
+
if (abs(i) > radius || abs(j) > radius) continue;
|
|
164
|
+
vec4 c = texture2D(tex, vUv + vec2(float(i), float(j)) * texel);
|
|
165
|
+
int q = (i <= 0 ? 0 : 1) + (j <= 0 ? 0 : 2);
|
|
166
|
+
m[q] += c.rgb * c.a; s[q] += c.rgb * c.rgb * c.a; a[q] += c.a;
|
|
167
|
+
}
|
|
168
|
+
float best = 1e9; vec3 col = c0.rgb;
|
|
169
|
+
for (int k = 0; k < 4; k++) {
|
|
170
|
+
if (a[k] < 0.5) continue;
|
|
171
|
+
vec3 mu = m[k] / a[k]; vec3 v = abs(s[k] / a[k] - mu * mu);
|
|
172
|
+
float vv = v.r + v.g + v.b;
|
|
173
|
+
if (vv < best) { best = vv; col = mu; }
|
|
174
|
+
}
|
|
175
|
+
gl_FragColor = vec4(col, c0.a);
|
|
176
|
+
#include <colorspace_fragment>
|
|
177
|
+
}`;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Per vertex and view (VIEWS order), 1 where that view sees the vertex and 0 where a nearer surface covers it,
|
|
181
|
+
* found with a depth buffer of the rest pose drawn the way each view looks at it.
|
|
182
|
+
*/
|
|
183
|
+
function visibility(rest: Float32Array, tris: number[]): Float32Array {
|
|
184
|
+
const R = 256;
|
|
185
|
+
const EPS = 0.015;
|
|
186
|
+
const count = rest.length / 3;
|
|
187
|
+
const out = new Float32Array(count * 4);
|
|
188
|
+
VIEWS.forEach(([, ax, sx, nax, ns], k) => {
|
|
189
|
+
const [a, n] = [{ x: 0, y: 1, z: 2 }[ax], { x: 0, y: 1, z: 2 }[nax]];
|
|
190
|
+
const px = (i: number) => (sx * rest[i * 3 + a] + 0.6) * R / 1.2;
|
|
191
|
+
const py = (i: number) => (1.05 - rest[i * 3 + 1]) * R / 1.1;
|
|
192
|
+
const pz = (i: number) => ns * rest[i * 3 + n];
|
|
193
|
+
const depth = new Float32Array(R * R).fill(-Infinity);
|
|
194
|
+
for (let t = 0; t < tris.length; t += 3) {
|
|
195
|
+
const [i0, i1, i2] = [tris[t], tris[t + 1], tris[t + 2]];
|
|
196
|
+
const [x0, y0, x1, y1, x2, y2] = [px(i0), py(i0), px(i1), py(i1), px(i2), py(i2)];
|
|
197
|
+
const area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
|
|
198
|
+
if (Math.abs(area) < 1e-9) continue;
|
|
199
|
+
for (let y = Math.max(0, Math.floor(Math.min(y0, y1, y2))); y <= Math.min(R - 1, Math.ceil(Math.max(y0, y1, y2))); y++) {
|
|
200
|
+
for (let x = Math.max(0, Math.floor(Math.min(x0, x1, x2))); x <= Math.min(R - 1, Math.ceil(Math.max(x0, x1, x2))); x++) {
|
|
201
|
+
const [cx, cy] = [x + 0.5, y + 0.5];
|
|
202
|
+
const w0 = ((x1 - cx) * (y2 - cy) - (x2 - cx) * (y1 - cy)) / area;
|
|
203
|
+
const w1 = ((x2 - cx) * (y0 - cy) - (x0 - cx) * (y2 - cy)) / area;
|
|
204
|
+
const w2 = 1 - w0 - w1;
|
|
205
|
+
if (w0 < 0 || w1 < 0 || w2 < 0) continue;
|
|
206
|
+
depth[y * R + x] = Math.max(depth[y * R + x], w0 * pz(i0) + w1 * pz(i1) + w2 * pz(i2));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (let i = 0; i < count; i++) {
|
|
211
|
+
const [x, y] = [Math.floor(px(i)), Math.floor(py(i))];
|
|
212
|
+
// The nearest surface around the vertex, so one on a silhouette edge is not hidden by a missed cell.
|
|
213
|
+
let near = -Infinity;
|
|
214
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
215
|
+
const [xx, yy] = [x + dx, y + dy];
|
|
216
|
+
if (xx >= 0 && yy >= 0 && xx < R && yy < R) near = Math.max(near, depth[yy * R + xx]);
|
|
217
|
+
}
|
|
218
|
+
out[i * 4 + k] = pz(i) >= near - EPS ? 1 : 0;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type Leg = { thigh: number; shin: number; hipY: number; ankleY: number };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Puts the bones where the skin was bound: the shape of the mesh itself (POSITION), which is the
|
|
228
|
+
* reconstructed figure the views are painted on and src/model/glb.ts measures and stands up. A rig may
|
|
229
|
+
* save the bones in another pose: Make-It-Animatable leaves them in its own T-pose, and for a figure in a
|
|
230
|
+
* long coat that pose sits, legs forward and back leant (lamp-quarter's courier, every walk frame seated).
|
|
231
|
+
* three.js binds glTF skins with an identity bind matrix, so a bone's bind world matrix is the mesh's world
|
|
232
|
+
* matrix times its inverse bind matrix. Parents first, before the figure is placed.
|
|
233
|
+
*/
|
|
234
|
+
function toBindPose(root: THREE.Object3D, skinned: THREE.SkinnedMesh) {
|
|
235
|
+
root.updateMatrixWorld(true);
|
|
236
|
+
const sk = skinned.skeleton;
|
|
237
|
+
const bind = new Map(sk.bones.map((b, i) => [b, skinned.matrixWorld.clone().multiply(sk.boneInverses[i].clone().invert())]));
|
|
238
|
+
const m = new THREE.Matrix4();
|
|
239
|
+
root.traverse((o) => {
|
|
240
|
+
const world = bind.get(o as THREE.Bone);
|
|
241
|
+
if (!world) return;
|
|
242
|
+
m.copy(o.parent!.matrixWorld).invert().multiply(world).decompose(o.position, o.quaternion, o.scale);
|
|
243
|
+
o.updateMatrixWorld(true);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class Figure3D {
|
|
248
|
+
readonly canvas = document.createElement('canvas');
|
|
249
|
+
/** Where the feet are in the canvas, as a share of its height (they are centred across). */
|
|
250
|
+
feetAt = 0.95;
|
|
251
|
+
private renderer: THREE.WebGLRenderer;
|
|
252
|
+
private scene = new THREE.Scene();
|
|
253
|
+
private camera = new THREE.OrthographicCamera(-0.5, 0.5, 1, 0, 0.01, 10);
|
|
254
|
+
private hemi = new THREE.HemisphereLight(0xffffff, 0x666666, 2);
|
|
255
|
+
private key = new THREE.DirectionalLight(0xffffff, 0);
|
|
256
|
+
private rim = new THREE.DirectionalLight(0xffffff, 0);
|
|
257
|
+
private rt = new THREE.WebGLRenderTarget(1, 1, { samples: 4 });
|
|
258
|
+
private post: THREE.ShaderMaterial;
|
|
259
|
+
private postScene = new THREE.Scene();
|
|
260
|
+
private postCam = new THREE.Camera();
|
|
261
|
+
private holder = new THREE.Group();
|
|
262
|
+
private bones: Record<string, THREE.Bone> = {};
|
|
263
|
+
private facial?: ModelFace;
|
|
264
|
+
private surfaceSources: Array<{ name: string; file: string; image: HTMLImageElement; texture: THREE.Texture }> = [];
|
|
265
|
+
private surfaces: Array<{ name: string; file: string; image: HTMLImageElement; texture: THREE.Texture; painter: PaintedFace; version: number }> = [];
|
|
266
|
+
get facialCapabilities() {
|
|
267
|
+
const morphs = this.facial?.mouth ?? false;
|
|
268
|
+
return { mouth: morphs || this.surfaces.length > 0, eyes: (this.facial?.eyes ?? false) || this.surfaces.length > 0,
|
|
269
|
+
method: morphs ? 'morphs' : this.surfaces.length ? 'painted-surfaces' : null, views: this.surfaces.map(s=>s.name) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Each view owns a canvas: sharing one would paint the last profile onto every side of the model. */
|
|
273
|
+
setFace(info: FaceDefinition, atlas: HTMLImageElement) {
|
|
274
|
+
this.surfaces = []; this.lastFrame=undefined;
|
|
275
|
+
for(const source of this.surfaceSources){source.texture.image=source.image;source.texture.needsUpdate=true;}
|
|
276
|
+
if (this.facial?.mouth) return;
|
|
277
|
+
const definition = this.facial?.eyes ? { ...info, sheets: Object.fromEntries(Object.entries(info.sheets).map(([key,poses])=>[key,poses.map(p=>p?{...p,eyes:[]}:null)])) } : info;
|
|
278
|
+
for (const source of this.surfaceSources) {
|
|
279
|
+
const pose = info.sheets[`model:${source.file}`]?.[0], view = pose?.atlas ?? 'default';
|
|
280
|
+
if (!pose?.lips || !info.anchors?.[view] || !info.frames?.[view] || (view!=='default'&&!info.atlases?.[view])) continue;
|
|
281
|
+
this.surfaces.push({...source,painter:new PaintedFace(definition,atlas,[view]),version:-1});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private paintFace(state: FaceState) {
|
|
286
|
+
for (const surface of this.surfaces) {
|
|
287
|
+
const image = surface.painter.draw(surface.image,`model:${surface.file}`,0,1,1,state,0,0,undefined,state);
|
|
288
|
+
if (surface.version === surface.painter.version) continue;
|
|
289
|
+
surface.version = surface.painter.version;
|
|
290
|
+
surface.texture.image = image;
|
|
291
|
+
surface.texture.needsUpdate = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
private rest: Record<string, { q: THREE.Quaternion; parentWorld: THREE.Quaternion }> = {};
|
|
295
|
+
private legs!: Record<'Left' | 'Right', Leg>;
|
|
296
|
+
private arms!: Record<'Left' | 'Right', THREE.Vector3>;
|
|
297
|
+
private hipsRest!: THREE.Vector3;
|
|
298
|
+
/** A creature: no bones, the whole body hops, squashes and leans; reach is how far it spreads from its centre, in heights. */
|
|
299
|
+
private creature = false;
|
|
300
|
+
private relaxed = false;
|
|
301
|
+
private reach = 0.45;
|
|
302
|
+
private seed = Math.random() * 10;
|
|
303
|
+
private lastFrame?: {frame:number;key:string};
|
|
304
|
+
get creatureBody() {
|
|
305
|
+
return this.creature;
|
|
306
|
+
}
|
|
307
|
+
/** Where the camera is, seen from the figure in its rest pose (it faces +z): picks the painted view to wear. */
|
|
308
|
+
private camDir = { value: new THREE.Vector3(0, 0, 1) };
|
|
309
|
+
|
|
310
|
+
static async load(info: ModelInfo, url: (file: string) => string, paint = 1): Promise<Figure3D> {
|
|
311
|
+
const f = new Figure3D(paint);
|
|
312
|
+
try {
|
|
313
|
+
await f.build(info, url);
|
|
314
|
+
return f;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
f.dispose();
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Frees the WebGL context (a browser keeps only a few), for a page that shows figures one after another. */
|
|
322
|
+
dispose() {
|
|
323
|
+
for (const source of this.surfaceSources) source.texture.dispose();
|
|
324
|
+
this.surfaceSources = []; this.surfaces = [];
|
|
325
|
+
this.rt.dispose();
|
|
326
|
+
this.renderer.dispose();
|
|
327
|
+
this.renderer.forceContextLoss();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Untextured: plain grey clay, so a guide shows only the form and the pose. */
|
|
331
|
+
plain() {
|
|
332
|
+
this.holder.traverse((o) => {
|
|
333
|
+
if ((o as THREE.Mesh).isMesh) (o as THREE.Mesh).material = new THREE.MeshStandardMaterial({ color: 0xb4b4b4, metalness: 0, roughness: 1 });
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private constructor(paint: number) {
|
|
338
|
+
this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, alpha: true, antialias: true, preserveDrawingBuffer: true, premultipliedAlpha: false });
|
|
339
|
+
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
340
|
+
this.renderer.setClearColor(0x000000, 0);
|
|
341
|
+
this.scene.add(this.hemi, this.key, this.rim, this.key.target, this.rim.target, this.holder);
|
|
342
|
+
this.post = new THREE.ShaderMaterial({
|
|
343
|
+
uniforms: { tex: { value: this.rt.texture }, texel: { value: new THREE.Vector2() }, radius: { value: paint } },
|
|
344
|
+
vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0., 1.); }',
|
|
345
|
+
fragmentShader: KUWAHARA,
|
|
346
|
+
transparent: true,
|
|
347
|
+
});
|
|
348
|
+
this.postScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.post));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private async build(info: ModelInfo, url: (file: string) => string) {
|
|
352
|
+
const gltf = await new GLTFLoader().loadAsync(url(info.glb));
|
|
353
|
+
const root = gltf.scene;
|
|
354
|
+
let skinned: THREE.SkinnedMesh | undefined;
|
|
355
|
+
let first: THREE.Mesh | undefined;
|
|
356
|
+
root.traverse((o) => {
|
|
357
|
+
if ((o as THREE.SkinnedMesh).isSkinnedMesh) skinned = o as THREE.SkinnedMesh;
|
|
358
|
+
if ((o as THREE.Mesh).isMesh) {
|
|
359
|
+
const mesh = o as THREE.Mesh;
|
|
360
|
+
first ??= mesh;
|
|
361
|
+
mesh.frustumCulled = false;
|
|
362
|
+
const map = (mesh.material as THREE.MeshStandardMaterial).map;
|
|
363
|
+
if (map) map.colorSpace = THREE.SRGBColorSpace;
|
|
364
|
+
mesh.geometry.computeVertexNormals();
|
|
365
|
+
mesh.material = new THREE.MeshStandardMaterial({ map, metalness: 0, roughness: 1 });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
// A creature's model is TRELLIS's mesh as it came, with no skeleton: it moves as one body.
|
|
369
|
+
this.creature = !skinned;
|
|
370
|
+
this.relaxed = !!info.relaxed && !!skinned;
|
|
371
|
+
if (!skinned && !(info.creature && first)) throw new Error(`${info.glb}: no skinned mesh`);
|
|
372
|
+
const body = (skinned ?? first)!;
|
|
373
|
+
if (skinned) {
|
|
374
|
+
for (const b of skinned.skeleton.bones) this.bones[b.name.replace(/^mixamorig:?/, '')] = b;
|
|
375
|
+
toBindPose(root, skinned);
|
|
376
|
+
}
|
|
377
|
+
this.facial = new ModelFace(root);
|
|
378
|
+
// Feet at 0, height 1, centred: measured on the mesh as posed now.
|
|
379
|
+
this.holder.add(root);
|
|
380
|
+
this.holder.updateMatrixWorld(true);
|
|
381
|
+
skinned?.skeleton.update();
|
|
382
|
+
const box = new THREE.Box3();
|
|
383
|
+
const v = new THREE.Vector3();
|
|
384
|
+
const pos = body.geometry.attributes.position;
|
|
385
|
+
const positions = new Float32Array(pos.count * 3);
|
|
386
|
+
for (let i = 0; i < pos.count; i++) {
|
|
387
|
+
box.expandByPoint(body.getVertexPosition(i, v).applyMatrix4(body.matrixWorld));
|
|
388
|
+
v.toArray(positions, i * 3);
|
|
389
|
+
}
|
|
390
|
+
const problem = shapeProblem(positions);
|
|
391
|
+
if (problem) throw new Error(`${info.glb}: ${problem}`);
|
|
392
|
+
const h = box.max.y - box.min.y;
|
|
393
|
+
const inner = new THREE.Group();
|
|
394
|
+
this.holder.add(inner);
|
|
395
|
+
inner.add(root);
|
|
396
|
+
inner.scale.setScalar(1 / h);
|
|
397
|
+
inner.position.set(-(box.min.x + box.max.x) / 2 / h, -box.min.y / h, -(box.min.z + box.max.z) / 2 / h);
|
|
398
|
+
this.holder.updateMatrixWorld(true);
|
|
399
|
+
if (info.texture === 'model') {
|
|
400
|
+
if (!(body.material as THREE.MeshStandardMaterial).map || !body.geometry.attributes.uv) {
|
|
401
|
+
throw new Error(`${info.glb}: model texture requires a base colour map and UV coordinates`);
|
|
402
|
+
}
|
|
403
|
+
// TRELLIS bakes its texture from a blurred reconstruction: the face comes out as a blank smudge and dark
|
|
404
|
+
// cloth darker still (a navy coat goes black). The body keeps the UV texture (one colour per surface, no
|
|
405
|
+
// seams between views), lifted to the painted views' brightness; the face wears the front painting, so it is the one Muse painted.
|
|
406
|
+
await this.paintViews(body, info, url, true);
|
|
407
|
+
} else {
|
|
408
|
+
await this.paintViews(body, info, url);
|
|
409
|
+
}
|
|
410
|
+
if (!skinned) {
|
|
411
|
+
// How far it spreads round its feet whichever way it turns (a cat is longer than it is tall): the frame fits it.
|
|
412
|
+
for (let i = 0; i < pos.count; i++) {
|
|
413
|
+
body.getVertexPosition(i, v).applyMatrix4(body.matrixWorld);
|
|
414
|
+
// matrixWorld already centres the mesh and makes its height 1; do not normalise twice.
|
|
415
|
+
this.reach = Math.max(this.reach, Math.hypot(v.x, v.z) + 0.06);
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
for (const [k, b] of Object.entries(this.bones)) this.rest[k] = { q: b.quaternion.clone(), parentWorld: b.parent!.getWorldQuaternion(new THREE.Quaternion()) };
|
|
420
|
+
const at = (k: string) => this.bones[k].getWorldPosition(new THREE.Vector3());
|
|
421
|
+
const leg = (s: string): Leg => {
|
|
422
|
+
const [hip, knee, ankle] = [at(`${s}UpLeg`), at(`${s}Leg`), at(`${s}Foot`)];
|
|
423
|
+
return { thigh: hip.distanceTo(knee), shin: knee.distanceTo(ankle), hipY: hip.y, ankleY: ankle.y };
|
|
424
|
+
};
|
|
425
|
+
this.legs = { Left: leg('Left'), Right: leg('Right') };
|
|
426
|
+
this.arms = { Left: at('LeftForeArm').sub(at('LeftArm')).normalize(), Right: at('RightForeArm').sub(at('RightArm')).normalize() };
|
|
427
|
+
if (info.coatCorrection !== false) this.fixCoat(skinned, 0.035);
|
|
428
|
+
this.hipsRest = this.bones.Hips.position.clone();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Muse's views, projected in the bind pose, so the paint rides on the surface while it moves. */
|
|
432
|
+
private async paintViews(skinned: THREE.Mesh, info: ModelInfo, url: (file: string) => string, headOnly = false) {
|
|
433
|
+
const g = skinned.geometry;
|
|
434
|
+
// A model Blender relaxed carries the surface as TRELLIS built it (_REST): the views were painted and fitted on that.
|
|
435
|
+
const original = g.attributes._rest as THREE.BufferAttribute | undefined;
|
|
436
|
+
const pos = original ?? g.attributes.position;
|
|
437
|
+
let nrm = g.attributes.normal;
|
|
438
|
+
if (original) {
|
|
439
|
+
const was = new THREE.BufferGeometry();
|
|
440
|
+
was.setAttribute('position', original);
|
|
441
|
+
if (g.index) was.setIndex(g.index);
|
|
442
|
+
was.computeVertexNormals();
|
|
443
|
+
nrm = was.attributes.normal;
|
|
444
|
+
}
|
|
445
|
+
const mw = skinned.matrixWorld;
|
|
446
|
+
const nm = new THREE.Matrix3().getNormalMatrix(mw);
|
|
447
|
+
const v = new THREE.Vector3();
|
|
448
|
+
const box = new THREE.Box3();
|
|
449
|
+
const rest = new Float32Array(pos.count * 3);
|
|
450
|
+
const restN = new Float32Array(pos.count * 3);
|
|
451
|
+
for (let i = 0; i < pos.count; i++) {
|
|
452
|
+
v.fromBufferAttribute(pos, i).applyMatrix4(mw).toArray(rest, i * 3);
|
|
453
|
+
box.expandByPoint(v);
|
|
454
|
+
v.fromBufferAttribute(nrm, i).applyMatrix3(nm).normalize().toArray(restN, i * 3);
|
|
455
|
+
}
|
|
456
|
+
// Same hero units as src/model/glb.ts normalise().
|
|
457
|
+
const h = box.max.y - box.min.y;
|
|
458
|
+
const centre = [(box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2];
|
|
459
|
+
for (let i = 0; i < rest.length; i++) rest[i] = (rest[i] - centre[i % 3]) / h;
|
|
460
|
+
g.setAttribute('restPos', new THREE.BufferAttribute(rest, 3));
|
|
461
|
+
g.setAttribute('restNrm', new THREE.BufferAttribute(restN, 3));
|
|
462
|
+
g.setAttribute('restVis', new THREE.BufferAttribute(visibility(rest, g.index ? Array.from(g.index.array) : [...Array(pos.count).keys()]), 4));
|
|
463
|
+
const uniforms: Record<string, THREE.IUniform> = {};
|
|
464
|
+
const painted = [0, 0, 0, 0];
|
|
465
|
+
const seen: Record<string, { image: HTMLImageElement; fit: { u: [number, number]; v: [number, number]; bands?: Array<[number, number]> }; bg: number[] }> = {};
|
|
466
|
+
await Promise.all(VIEWS.map(async ([name]) => {
|
|
467
|
+
const img = new Image();
|
|
468
|
+
img.src = url(info.views[name].image);
|
|
469
|
+
await img.decode();
|
|
470
|
+
// Each painting's own background, which marks where the figure is not: the median of its border, as
|
|
471
|
+
// src/model/fit.ts finds it (a corner alone can be a lighter vignette).
|
|
472
|
+
const R = 32;
|
|
473
|
+
const c = Object.assign(document.createElement('canvas'), { width: R, height: R }).getContext('2d', { willReadFrequently: true })!;
|
|
474
|
+
c.drawImage(img, 0, 0, R, R);
|
|
475
|
+
const px = c.getImageData(0, 0, R, R).data;
|
|
476
|
+
const border = [...Array(R).keys()].flatMap((i) => [[i, 0], [i, R - 1], [0, i], [R - 1, i]]).map(([x, y]) => (y * R + x) * 4);
|
|
477
|
+
const median = (ch: number) => border.map((at) => px[at + ch]).sort((a, b) => a - b)[border.length >> 1];
|
|
478
|
+
uniforms[`bg_${name}`] = { value: new THREE.Vector3(...[0, 1, 2].map((ch) => (median(ch) / 255) ** 2.2)) };
|
|
479
|
+
// The figure's mean colour in this painting (linear), away from its background: the UV texture is matched to it.
|
|
480
|
+
const bg = [0, 1, 2].map(median);
|
|
481
|
+
for (let at = 0; at < px.length; at += 4) {
|
|
482
|
+
if (Math.abs(px[at] - bg[0]) + Math.abs(px[at + 1] - bg[1]) + Math.abs(px[at + 2] - bg[2]) < 60) continue;
|
|
483
|
+
for (let ch = 0; ch < 3; ch++) painted[ch] += (px[at + ch] / 255) ** 2.2;
|
|
484
|
+
painted[3]++;
|
|
485
|
+
}
|
|
486
|
+
const tex = new THREE.Texture(img);
|
|
487
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
488
|
+
tex.needsUpdate = true;
|
|
489
|
+
const f = info.views[name];
|
|
490
|
+
seen[name] = { image: img, fit: f, bg };
|
|
491
|
+
uniforms[`p_${name}`] = { value: tex };
|
|
492
|
+
this.surfaceSources.push({name,file:f.image,image:img,texture:tex});
|
|
493
|
+
uniforms[`f_${name}`] = { value: new THREE.Vector4(f.u[0], f.u[1], f.v[0], f.v[1]) };
|
|
494
|
+
uniforms[`b_${name}`] = { value: (f.bands?.length === BANDS ? f.bands : Array(BANDS).fill(f.u)).map(([a, b]) => new THREE.Vector2(a, b)) };
|
|
495
|
+
}));
|
|
496
|
+
uniforms.camDir = this.camDir;
|
|
497
|
+
uniforms.headOnly = { value: headOnly ? 1 : 0 };
|
|
498
|
+
uniforms.faceFromViews = { value: info.face === 'views' ? 1 : 0 };
|
|
499
|
+
uniforms.uvMat = { value: new THREE.Matrix3() };
|
|
500
|
+
uniforms.uvOff = { value: new THREE.Vector3() };
|
|
501
|
+
const map = (skinned.material as THREE.MeshStandardMaterial).map;
|
|
502
|
+
if (headOnly && map?.image && painted[3]) {
|
|
503
|
+
const fit = matchColours(map.image as CanvasImageSource, g.attributes.uv as THREE.BufferAttribute, rest, restN, g.attributes.restVis as THREE.BufferAttribute, seen);
|
|
504
|
+
if (fit) { (uniforms.uvMat.value as THREE.Matrix3).copy(fit.m); (uniforms.uvOff.value as THREE.Vector3).copy(fit.o); }
|
|
505
|
+
else {
|
|
506
|
+
const uv = meanColour(map.image as CanvasImageSource);
|
|
507
|
+
if (uv) (uniforms.uvMat.value as THREE.Matrix3).set(...([0, 1, 2].flatMap((r) => [0, 1, 2].map((c) => (r === c ? Math.min(2.5, Math.max(0.8, painted[r] / painted[3] / uv[r])) : 0)))) as [number, number, number, number, number, number, number, number, number]);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const decl = VIEWS.map(([k]) => `uniform sampler2D p_${k}; uniform vec4 f_${k}; uniform vec2 b_${k}[${BANDS}]; uniform vec3 bg_${k};`).join('\n');
|
|
511
|
+
// Which painted view a surface wears: the one nearest the camera, wherever that view sees the surface at
|
|
512
|
+
// all (it faces the view, nothing nearer covers it, the painting shows figure there, not background); the
|
|
513
|
+
// others only fill what it cannot see. Views are fitted by silhouette and never line up exactly, so two
|
|
514
|
+
// views on one head show its face twice (the profile's eye beside the front's); one view at a time does not.
|
|
515
|
+
// Under the arm in a profile the sweater is covered, so the arm's paint stays on the arm. The front is the
|
|
516
|
+
// painting the hero was cast from, so it keeps the figure until the camera is well round the side.
|
|
517
|
+
const sample = VIEWS.map(([k, ax, sx, nax, ns], i) => `
|
|
518
|
+
ab = mix(b_${k}[band], b_${k}[band2], bandT);
|
|
519
|
+
c[${i}] = texture2D(p_${k}, vec2(ab.x * ${sx.toFixed(1)} * vRest.${ax} + ab.y, 1. - (f_${k}.z * vRest.y + f_${k}.w))).rgb;
|
|
520
|
+
facing[${i}] = ${ns.toFixed(1)} * rn.${nax};
|
|
521
|
+
paint[${i}] = notBg(c[${i}], bg_${k});
|
|
522
|
+
score[${i}] = ${TRUST[k].toFixed(2)} + camDir.${nax} * ${ns.toFixed(1)} - 2. * (1. - smoothstep(mix(0.05, -0.45, head), mix(0.25, -0.2, head), facing[${i}])) - 2. * (1. - head) * (1. - vVis[${i}]) - 2. * (1. - paint[${i}]);`).join('');
|
|
523
|
+
const m = skinned.material as THREE.MeshStandardMaterial;
|
|
524
|
+
m.onBeforeCompile = (sh) => {
|
|
525
|
+
Object.assign(sh.uniforms, uniforms);
|
|
526
|
+
sh.vertexShader = 'attribute vec3 restPos; attribute vec3 restNrm; attribute vec4 restVis; varying vec3 vRest; varying vec3 vRestN; varying vec4 vVis;\n'
|
|
527
|
+
+ sh.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\n vRest = restPos; vRestN = restNrm; vVis = restVis;');
|
|
528
|
+
sh.fragmentShader = `${decl}
|
|
529
|
+
uniform vec3 camDir; uniform float headOnly; uniform float faceFromViews; uniform mat3 uvMat; uniform vec3 uvOff; varying vec3 vRest; varying vec3 vRestN; varying vec4 vVis;
|
|
530
|
+
// Two views blend only in a narrow band where they claim a surface about equally.
|
|
531
|
+
#define SHARP 28.
|
|
532
|
+
float notBg(vec3 c, vec3 bg) { return smoothstep(0.14, 0.32, abs(c.r - bg.r) + abs(c.g - bg.g) + abs(c.b - bg.b)); }
|
|
533
|
+
` + sh.fragmentShader.replace('#include <map_fragment>', `#include <map_fragment>
|
|
534
|
+
vec3 rn = normalize(vRestN); vec3 c[4]; float facing[4]; float score[4]; float paint[4]; vec2 ab;
|
|
535
|
+
// Between the centres of the two nearest slices of the height.
|
|
536
|
+
float bandAt = clamp(vRest.y * ${BANDS}. - 0.5, 0., ${BANDS - 1}.);
|
|
537
|
+
int band = int(floor(bandAt)); int band2 = min(band + 1, ${BANDS - 1}); float bandT = bandAt - floor(bandAt);
|
|
538
|
+
// On the head, the view nearest the camera paints round to the far side of the cheek, even at a glancing
|
|
539
|
+
// angle: a profile's eye and nose land on the TRELLIS cheek beside the front's face (two faces), so no other
|
|
540
|
+
// view fills there. A slightly stretched cheek reads as a cheek; a second face never does.
|
|
541
|
+
float head = smoothstep(${HEAD_FROM.toFixed(3)}, ${(HEAD_FROM + 0.03).toFixed(3)}, vRest.y);${sample}
|
|
542
|
+
float best = max(max(score[0], score[1]), max(score[2], score[3]));
|
|
543
|
+
vec3 acc = vec3(0.); float wsum = 0.;
|
|
544
|
+
for (int i = 0; i < 4; i++) { float w = exp(SHARP * (score[i] - best)) * step(0.001, facing[i]); acc += c[i] * w; wsum += w; }
|
|
545
|
+
// No view claims it (the crown of the head faces up, at no view): every view that shows figure there, by how
|
|
546
|
+
// much it faces it.
|
|
547
|
+
if (best <= -1.) { acc = vec3(0.); wsum = 0.;
|
|
548
|
+
for (int i = 0; i < 4; i++) { float w = pow(max(0., facing[i]), 4.) * paint[i] + 1e-6 * paint[i]; acc += c[i] * w; wsum += w; } }
|
|
549
|
+
// Over the UV texture only the front painting goes on the face, and only where the face looks forward:
|
|
550
|
+
// two views meeting on a head leave a second eye at the seam, the UV texture round the side never does.
|
|
551
|
+
// Only where the front painting sees the surface (vVis.x): a ponytail or bun behind the head faces forward
|
|
552
|
+
// too, and wore a second face. And it fades as the camera goes round to the side: seen edge-on, the front
|
|
553
|
+
// painting smears across the profile, while the model's own texture there is a clean cheek.
|
|
554
|
+
if (headOnly > 0.5) diffuseColor.rgb = mix(max(vec3(0.), uvMat * diffuseColor.rgb + uvOff), c[0], faceFromViews * head * paint[0] * smoothstep(0.4, 0.7, facing[0])
|
|
555
|
+
* vVis[0] * smoothstep(0.3, 0.65, camDir.z));
|
|
556
|
+
else if (wsum > 0.) diffuseColor.rgb = acc / wsum;`);
|
|
557
|
+
};
|
|
558
|
+
m.needsUpdate = true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// A long coat is skinned to the thighs and tears between the legs: weight on a leg bone for a vertex
|
|
562
|
+
// further than `radius` (figure heights) from that leg's bones moves to the hips. Only correct above
|
|
563
|
+
// the knee: a wide boot shaft also extends beyond that radius, but must stay attached to its leg.
|
|
564
|
+
private fixCoat(skinned: THREE.SkinnedMesh, radius: number) {
|
|
565
|
+
const sk = skinned.skeleton;
|
|
566
|
+
const idx = skinned.geometry.attributes.skinIndex;
|
|
567
|
+
const wt = skinned.geometry.attributes.skinWeight;
|
|
568
|
+
const pos = skinned.geometry.attributes.position;
|
|
569
|
+
const hips = sk.bones.indexOf(this.bones.Hips);
|
|
570
|
+
const at = (k: string) => this.bones[k].getWorldPosition(new THREE.Vector3());
|
|
571
|
+
const toe = (s: string) => (this.bones[`${s}ToeBase`] ? at(`${s}ToeBase`) : at(`${s}Foot`));
|
|
572
|
+
const segs = { Left: [at('LeftUpLeg'), at('LeftLeg'), at('LeftFoot'), toe('Left')], Right: [at('RightUpLeg'), at('RightLeg'), at('RightFoot'), toe('Right')] };
|
|
573
|
+
const v = new THREE.Vector3();
|
|
574
|
+
const c = new THREE.Vector3();
|
|
575
|
+
const line = new THREE.Line3();
|
|
576
|
+
for (let i = 0; i < pos.count; i++) {
|
|
577
|
+
skinned.getVertexPosition(i, v).applyMatrix4(skinned.matrixWorld);
|
|
578
|
+
for (let j = 0; j < 4; j++) {
|
|
579
|
+
const w = wt.getComponent(i, j);
|
|
580
|
+
const name = sk.bones[idx.getComponent(i, j)]?.name.replace(/^mixamorig:?/, '') ?? '';
|
|
581
|
+
const side = name.startsWith('Left') ? 'Left' : name.startsWith('Right') ? 'Right' : null;
|
|
582
|
+
if (!w || !side || !/UpLeg$|Leg$/.test(name)) continue;
|
|
583
|
+
const [a, b, f, t] = segs[side];
|
|
584
|
+
if (v.y <= b.y + 1e-6) continue;
|
|
585
|
+
let d = Infinity;
|
|
586
|
+
for (const [p, q] of [[a, b], [b, f], [f, t]]) d = Math.min(d, line.set(p, q).closestPointToPoint(v, true, c).distanceTo(v));
|
|
587
|
+
const k = Math.max(0, Math.min(1, (d - radius) / radius));
|
|
588
|
+
if (!k) continue;
|
|
589
|
+
wt.setComponent(i, j, w * (1 - k));
|
|
590
|
+
let slot = -1;
|
|
591
|
+
for (let s = 0; s < 4; s++) if (idx.getComponent(i, s) === hips) slot = s;
|
|
592
|
+
if (slot < 0) for (let s = 0; s < 4; s++) if (slot < 0 || wt.getComponent(i, s) < wt.getComponent(i, slot)) slot = s;
|
|
593
|
+
if (idx.getComponent(i, slot) !== hips) { idx.setComponent(i, slot, hips); wt.setComponent(i, slot, 0); }
|
|
594
|
+
wt.setComponent(i, slot, wt.getComponent(i, slot) + w * k);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
idx.needsUpdate = true;
|
|
598
|
+
wt.needsUpdate = true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Rotates a bone by R about world axes, measured from its rest pose. */
|
|
602
|
+
private turnBone(name: string, R: THREE.Quaternion) {
|
|
603
|
+
const b = this.bones[name];
|
|
604
|
+
const r = this.rest[name];
|
|
605
|
+
if (!b || !r) return;
|
|
606
|
+
b.quaternion.copy(r.parentWorld.clone().invert().multiply(R).multiply(r.parentWorld).multiply(r.q));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** The walk at `phase` (cycles; the left foot strikes at 0), feet planted like src/play/rig.ts. */
|
|
610
|
+
private pose(phase: number, walking: boolean, armSwing: number, gesture?: number) {
|
|
611
|
+
const step = CYCLE * STANCE;
|
|
612
|
+
const L = this.legs.Left;
|
|
613
|
+
const reach = (L.thigh + L.shin) * 0.985;
|
|
614
|
+
let hipH = L.hipY - L.ankleY;
|
|
615
|
+
const feet = {
|
|
616
|
+
Left: walking ? footAt(phase, step) : { dx: 0.02, lift: 0, stance: true, u: 0 },
|
|
617
|
+
Right: walking ? footAt(phase + 0.5, step) : { dx: -0.02, lift: 0, stance: true, u: 0 },
|
|
618
|
+
};
|
|
619
|
+
for (const f of Object.values(feet)) hipH = Math.min(hipH, Math.sqrt(Math.max(0, reach * reach - f.dx * f.dx)) + f.lift * LIFT);
|
|
620
|
+
for (const side of ['Left', 'Right'] as const) {
|
|
621
|
+
const f = feet[side];
|
|
622
|
+
const { thigh, shin } = this.legs[side];
|
|
623
|
+
const [tx, ty] = [f.dx, -hipH + f.lift * LIFT];
|
|
624
|
+
const r = Math.min(Math.hypot(tx, ty), (thigh + shin) * 0.999);
|
|
625
|
+
const knee = Math.PI - Math.acos(Math.max(-1, Math.min(1, (thigh * thigh + shin * shin - r * r) / (2 * thigh * shin))));
|
|
626
|
+
const hipFlex = Math.atan2(tx, -ty) + Math.acos(Math.max(-1, Math.min(1, (thigh * thigh + r * r - shin * shin) / (2 * thigh * r))));
|
|
627
|
+
let foot = -(hipFlex - knee);
|
|
628
|
+
if (!f.stance) foot += swingFootPitch(f.u);
|
|
629
|
+
this.turnBone(`${side}UpLeg`, turn(X, -hipFlex));
|
|
630
|
+
this.turnBone(`${side}Leg`, turn(X, knee));
|
|
631
|
+
this.turnBone(`${side}Foot`, turn(X, -foot));
|
|
632
|
+
}
|
|
633
|
+
const ph = phase * Math.PI * 2;
|
|
634
|
+
const hips = this.bones.Hips;
|
|
635
|
+
hips.position.copy(this.hipsRest);
|
|
636
|
+
hips.position.y -= (L.hipY - L.ankleY - hipH) / hips.parent!.getWorldScale(new THREE.Vector3()).y;
|
|
637
|
+
const twist = walking ? 0.12 * Math.cos(ph) : 0;
|
|
638
|
+
this.turnBone('Hips', turn(Y, twist).multiply(turn(Z, walking ? 0.03 * Math.cos(ph) : 0)));
|
|
639
|
+
this.turnBone('Spine', turn(Y, -twist * 0.6));
|
|
640
|
+
this.turnBone('Spine1', turn(X, walking ? 0.05 : 0));
|
|
641
|
+
this.turnBone('Spine2', turn(Y, -twist * 0.6));
|
|
642
|
+
for (const side of ['Left', 'Right'] as const) {
|
|
643
|
+
const sign = side === 'Left' ? 1 : -1;
|
|
644
|
+
const a = walking ? armSwing * Math.cos(ph + (side === 'Left' ? Math.PI : 0)) : 0;
|
|
645
|
+
// A relaxed rest pose (Blender) hangs the arms close to the body; otherwise wider, or sleeves sink into the coat.
|
|
646
|
+
const out = this.relaxed ? 0.09 : 0.22;
|
|
647
|
+
const down = new THREE.Vector3(sign * Math.sin(out), -Math.cos(out), 0);
|
|
648
|
+
this.turnBone(`${side}Arm`, turn(X, -a).multiply(new THREE.Quaternion().setFromUnitVectors(this.arms[side], down)));
|
|
649
|
+
this.turnBone(`${side}ForeArm`, turn(X, -(0.25 + Math.max(0, a) * 0.6)));
|
|
650
|
+
}
|
|
651
|
+
this.turnBone('Neck', new THREE.Quaternion());
|
|
652
|
+
this.turnBone('Head', new THREE.Quaternion());
|
|
653
|
+
const g: GesturePose | undefined = gesture === undefined ? undefined : GESTURES[gesture % GESTURES.length].pose;
|
|
654
|
+
if (!g) return;
|
|
655
|
+
for (const side of ['Left', 'Right'] as const) {
|
|
656
|
+
const arm = side === 'Left' ? g.left : g.right;
|
|
657
|
+
if (!arm) continue;
|
|
658
|
+
const dir = new THREE.Vector3(arm[0], arm[1], arm[2]).normalize();
|
|
659
|
+
this.turnBone(`${side}Arm`, new THREE.Quaternion().setFromUnitVectors(this.arms[side], dir));
|
|
660
|
+
this.turnBone(`${side}ForeArm`, turn(X, -arm[3]));
|
|
661
|
+
}
|
|
662
|
+
this.turnBone('Spine1', turn(X, g.lean ?? 0));
|
|
663
|
+
this.turnBone('Head', turn(X, g.nod ?? 0).multiply(turn(Z, g.tilt ?? 0)));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* One frame, `px` pixels tall. yaw: 0 faces the viewer, PI/2 faces screen right. elevation: how far
|
|
668
|
+
* the camera looks down (radians). Returns the canvas, feet at (width / 2, height * feetAt).
|
|
669
|
+
*/
|
|
670
|
+
draw({ phase = 0, walking = false, yaw = 0, elevation = 0.18, px = 300, light, armSwing, gesture, time = performance.now() / 1000, talk, face, frame }: {
|
|
671
|
+
phase?: number; walking?: boolean; yaw?: number; elevation?: number; px?: number; light?: FigureLight; armSwing?: number; frame?: number;
|
|
672
|
+
/** A talking pose (index into src/play/gestures.ts GESTURES), standing. A creature only bounces on its words. */
|
|
673
|
+
gesture?: number;
|
|
674
|
+
/** Seconds, and whether it speaks or listens: a creature breathes, blinks and bounces with them. */
|
|
675
|
+
time?: number; talk?: 'speak' | 'listen'; face?: FaceState;
|
|
676
|
+
}): HTMLCanvasElement {
|
|
677
|
+
const key=frame===undefined?'':JSON.stringify([phase,walking,yaw,elevation,px,light,armSwing,gesture,time,talk,face&&[face.mouth,face.previous,face.mix.toFixed(2),face.blink.toFixed(2),face.brow.toFixed(2),face.jaw.toFixed(2),face.round.toFixed(2)]]);
|
|
678
|
+
if(frame!==undefined&&this.lastFrame?.frame===frame&&this.lastFrame.key===key)return this.canvas;
|
|
679
|
+
// Feet at 0: how much of the frame is above and below them, and how wide it is either side, in heights.
|
|
680
|
+
let [top, bottom, half] = [1.06, -0.06, 0.45];
|
|
681
|
+
if (this.creature) {
|
|
682
|
+
const m = creatureMotion({ phase, walking: walking && gesture === undefined, time, talk: gesture !== undefined ? 'speak' : talk, seed: this.seed });
|
|
683
|
+
this.holder.rotation.order = 'YXZ';
|
|
684
|
+
this.holder.rotation.x = m.lean;
|
|
685
|
+
this.holder.position.y = m.lift;
|
|
686
|
+
this.holder.scale.set(1 / Math.sqrt(m.stretch), m.stretch, 1 / Math.sqrt(m.stretch));
|
|
687
|
+
// Room for the highest hop and a stretched take-off, and for a long body turned side on.
|
|
688
|
+
[top, bottom, half] = [1.55, -0.06, this.reach];
|
|
689
|
+
} else this.pose(phase, walking && gesture === undefined, armSwing ?? (this.relaxed ? 0.32 : 0.12), gesture);
|
|
690
|
+
if (face) { this.facial?.apply(face); this.paintFace(face); }
|
|
691
|
+
this.holder.rotation.y = yaw;
|
|
692
|
+
this.camDir.value.set(-Math.sin(yaw), 0, Math.cos(yaw));
|
|
693
|
+
this.holder.updateMatrixWorld(true);
|
|
694
|
+
const [w, h] = [Math.ceil(px * 2 * half), Math.ceil(px * (top - bottom))];
|
|
695
|
+
const gl = this.renderer.getContext();
|
|
696
|
+
const limit = Math.min(this.renderer.capabilities.maxTextureSize, gl.getParameter(gl.MAX_RENDERBUFFER_SIZE));
|
|
697
|
+
if (gl.isContextLost() || !Number.isFinite(w) || !Number.isFinite(h) || w < 1 || h < 1 || w > limit || h > limit || w * h > 16_777_216) {
|
|
698
|
+
throw new Error(`3D figure frame ${w}x${h} cannot be rendered safely (device limit ${limit})`);
|
|
699
|
+
}
|
|
700
|
+
if (this.canvas.width !== w || this.canvas.height !== h) {
|
|
701
|
+
this.renderer.setSize(w, h, false);
|
|
702
|
+
this.rt.setSize(w, h);
|
|
703
|
+
(this.post.uniforms.texel.value as THREE.Vector2).set(1 / w, 1 / h);
|
|
704
|
+
}
|
|
705
|
+
this.feetAt = top / (top - bottom);
|
|
706
|
+
const cam = this.camera;
|
|
707
|
+
Object.assign(cam, { left: -half, right: half, top: top * Math.cos(elevation), bottom });
|
|
708
|
+
cam.position.set(0, Math.sin(elevation) * 5, Math.cos(elevation) * 5);
|
|
709
|
+
cam.lookAt(0, 0, 0);
|
|
710
|
+
cam.updateProjectionMatrix();
|
|
711
|
+
const L = light ?? PAINTED_LIGHT;
|
|
712
|
+
this.hemi.color.set(L.sky);
|
|
713
|
+
this.hemi.groundColor.set(L.ground);
|
|
714
|
+
this.hemi.intensity = L.ambient;
|
|
715
|
+
for (const [lamp, spec] of [[this.key, L.key], [this.rim, L.rim]] as const) {
|
|
716
|
+
lamp.intensity = spec?.intensity ?? 0;
|
|
717
|
+
if (spec) { lamp.color.set(spec.color); lamp.position.set(...spec.dir); }
|
|
718
|
+
}
|
|
719
|
+
this.renderer.setRenderTarget(this.rt);
|
|
720
|
+
this.renderer.clear();
|
|
721
|
+
this.renderer.render(this.scene, cam);
|
|
722
|
+
this.renderer.setRenderTarget(null);
|
|
723
|
+
this.renderer.clear();
|
|
724
|
+
this.renderer.render(this.postScene, this.postCam);
|
|
725
|
+
if(frame!==undefined)this.lastFrame={frame,key};
|
|
726
|
+
return this.canvas;
|
|
727
|
+
}
|
|
728
|
+
}
|