@docyrus/animaguru 0.27.0 → 0.29.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/README.md +28 -2
- package/dist/animaguru.js +933 -664
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +4 -1
- package/skills/animaguru-3d-models/SKILL.md +86 -0
- package/skills/animaguru-character-stage-3d/SKILL.md +35 -0
- package/skills/animaguru-character-stage-3d/references/recipe.md +26 -0
- package/skills/animaguru-exploded-teardown-3d/SKILL.md +38 -0
- package/skills/animaguru-exploded-teardown-3d/references/recipe.md +20 -0
- package/skills/animaguru-product-turntable-3d/SKILL.md +38 -0
- package/skills/animaguru-product-turntable-3d/references/recipe.md +27 -0
- package/skills/animaguru-quiz/SKILL.md +1 -1
- package/skills/animaguru-toy-diorama-3d/SKILL.md +33 -0
- package/skills/animaguru-toy-diorama-3d/references/recipe.md +26 -0
- package/skills/animaguru-video/SKILL.md +2 -2
- package/templates/video/AGENTS.md +1 -1
- package/templates/video/README.md +5 -1
- package/templates/video/package.json +3 -1
- package/templates/video/scripts/export-mp4.mjs +2 -2
- package/templates/video/scripts/install-example.mjs +10 -6
- package/templates/video/shared/locales/en/style-studies.json +35 -0
- package/templates/video/shared/locales/en/timeline.json +1 -0
- package/templates/video/src/kit/index.ts +1 -0
- package/templates/video/src/kit/three-stage.ts +262 -0
- package/templates/video/src/resource-timeline.ts +1 -0
- package/templates/video/src/studies/three/character-stage-3d.ts +172 -0
- package/templates/video/src/studies/three/exploded-teardown-3d.ts +214 -0
- package/templates/video/src/studies/three/index.ts +16 -0
- package/templates/video/src/studies/three/product-turntable-3d.ts +160 -0
- package/templates/video/src/studies/three/toy-diorama-3d.ts +245 -0
- package/templates/video/src/studies/three-film.ts +42 -0
- package/templates/video/src/style.css +2 -1
- package/templates/video/src/timeline-model.ts +1 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { FillGradient, Graphics, type Text } from "pixi.js";
|
|
2
|
+
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
|
|
3
|
+
import { THREE, ThreeStage, clamp01, easeInOut, lerp, rng, show, txt, win } from "../../kit";
|
|
4
|
+
import type { ThreeStudy } from ".";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
3D Exploded Teardown study: an invented handheld console modelled in code lies on a studio-grey set while the
|
|
8
|
+
camera orbits; its parts lift apart along the assembly axis in order, hold, and close again. Each part keeps a
|
|
9
|
+
numbered callout that follows it through the turn (the battery is named), drawn in Pixi over the three.js stage.
|
|
10
|
+
Parts, dimensions and the console are illustrative. Replace them with the product's own CAD (exported to GLB)
|
|
11
|
+
for a real product. Every value is a function of seconds.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const W = 3.3, D = 1.62, R = 0.4;
|
|
15
|
+
const INK = 0x1d2226, ACCENT = 0xe2552b;
|
|
16
|
+
|
|
17
|
+
function rounded(width: number, height: number, radius: number, cx = 0, cy = 0, path = new THREE.Shape()) {
|
|
18
|
+
const x = cx - width / 2, y = cy - height / 2, r = Math.min(radius, width / 2, height / 2);
|
|
19
|
+
path.moveTo(x + r, y);
|
|
20
|
+
path.lineTo(x + width - r, y); path.quadraticCurveTo(x + width, y, x + width, y + r);
|
|
21
|
+
path.lineTo(x + width, y + height - r); path.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
|
22
|
+
path.lineTo(x + r, y + height); path.quadraticCurveTo(x, y + height, x, y + height - r);
|
|
23
|
+
path.lineTo(x, y + r); path.quadraticCurveTo(x, y, x + r, y);
|
|
24
|
+
return path;
|
|
25
|
+
}
|
|
26
|
+
function circle(x: number, y: number, r: number) { const p = new THREE.Path(); p.absarc(x, y, r, 0, Math.PI * 2, true); return p; }
|
|
27
|
+
function cross(cx: number, cy: number, arm: number, width: number) {
|
|
28
|
+
const s = new THREE.Shape(), w = width / 2;
|
|
29
|
+
([[-w, arm], [w, arm], [w, w], [arm, w], [arm, -w], [w, -w], [w, -arm], [-w, -arm], [-w, -w], [-arm, -w], [-arm, w], [-w, w]] as const)
|
|
30
|
+
.forEach(([x, y], i) => (i ? s.lineTo(cx + x, cy + y) : s.moveTo(cx + x, cy + y)));
|
|
31
|
+
s.closePath();
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
/** A plan Shape (x right, y toward the back) extruded upward from y = 0 with softened edges. */
|
|
35
|
+
function slab(shape: THREE.Shape, depth: number, bevel = 0.02) {
|
|
36
|
+
const geometry = new THREE.ExtrudeGeometry(shape, { depth: depth - bevel * 2, bevelEnabled: bevel > 0, bevelThickness: bevel, bevelSize: bevel, bevelSegments: 4, curveSegments: 24 });
|
|
37
|
+
geometry.rotateX(-Math.PI / 2);
|
|
38
|
+
geometry.translate(0, bevel, 0);
|
|
39
|
+
return geometry;
|
|
40
|
+
}
|
|
41
|
+
function mesh(geometry: THREE.BufferGeometry, material: THREE.Material, x = 0, y = 0, z = 0) {
|
|
42
|
+
const m = new THREE.Mesh(geometry, material);
|
|
43
|
+
m.position.set(x, y, z);
|
|
44
|
+
m.castShadow = m.receiveShadow = true;
|
|
45
|
+
return m;
|
|
46
|
+
}
|
|
47
|
+
/** Seeded board artwork, so every render draws the same traces. */
|
|
48
|
+
function boardTexture() {
|
|
49
|
+
const canvas = document.createElement("canvas");
|
|
50
|
+
canvas.width = 1024; canvas.height = 512;
|
|
51
|
+
const g = canvas.getContext("2d")!, next = rng(81);
|
|
52
|
+
g.fillStyle = "#1b5a45"; g.fillRect(0, 0, 1024, 512);
|
|
53
|
+
g.lineCap = "round";
|
|
54
|
+
for (let i = 0; i < 90; i++) {
|
|
55
|
+
let x = next() * 1024, y = next() * 512;
|
|
56
|
+
g.strokeStyle = next() < 0.2 ? "rgba(214,176,92,.85)" : "rgba(64,140,104,.9)";
|
|
57
|
+
g.lineWidth = next() < 0.2 ? 4 : 2;
|
|
58
|
+
g.beginPath(); g.moveTo(x, y);
|
|
59
|
+
for (let s = 0; s < 3; s++) { const len = 30 + next() * 110, dir = Math.floor(next() * 8) * Math.PI / 4; x += Math.cos(dir) * len; y += Math.sin(dir) * len; g.lineTo(x, y); }
|
|
60
|
+
g.stroke();
|
|
61
|
+
g.fillStyle = "#d8b45e"; g.beginPath(); g.arc(x, y, 4, 0, Math.PI * 2); g.fill();
|
|
62
|
+
}
|
|
63
|
+
const map = new THREE.CanvasTexture(canvas);
|
|
64
|
+
map.colorSpace = THREE.SRGBColorSpace;
|
|
65
|
+
return map;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Part = { group: THREE.Group; number: string; side: -1 | 1; lift: number; order: number; anchor: THREE.Vector3 };
|
|
69
|
+
|
|
70
|
+
const study: ThreeStudy = async (root, lines) => {
|
|
71
|
+
const stage = new ThreeStage({ width: 1920, height: 1080 });
|
|
72
|
+
const scene = new THREE.Scene();
|
|
73
|
+
scene.environment = stage.room();
|
|
74
|
+
scene.environmentIntensity = 0.9;
|
|
75
|
+
const camera = new THREE.PerspectiveCamera(28, 1920 / 1080, 0.1, 80);
|
|
76
|
+
|
|
77
|
+
const pbr = (color: number, roughness: number, extra: THREE.MeshPhysicalMaterialParameters = {}) => new THREE.MeshPhysicalMaterial({ color, roughness, ...extra });
|
|
78
|
+
const shell = pbr(0xeae6df, 0.48, { clearcoat: 0.25, clearcoatRoughness: 0.5 });
|
|
79
|
+
const shellDark = pbr(0x5d6369, 0.5, { clearcoat: 0.2, clearcoatRoughness: 0.55 });
|
|
80
|
+
const chip = pbr(0x1b1d20, 0.42);
|
|
81
|
+
const metal = new THREE.MeshStandardMaterial({ color: 0xb9bec4, metalness: 1, roughness: 0.3 });
|
|
82
|
+
|
|
83
|
+
const parts: Part[] = [];
|
|
84
|
+
const part = (number: string, side: -1 | 1, lift: number, order: number, anchor: [number, number, number]) => {
|
|
85
|
+
const group = new THREE.Group();
|
|
86
|
+
parts.push({ group, number, side, lift, order, anchor: new THREE.Vector3(...anchor) });
|
|
87
|
+
return group;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const back = part("6", 1, -0.95, 0, [1.55, 0.24, 0.2]);
|
|
91
|
+
back.add(mesh(slab(rounded(W, D, R), 0.07, 0.03), shellDark));
|
|
92
|
+
const wall = rounded(W, D, R); wall.holes.push(rounded(W - 0.12, D - 0.12, R - 0.06));
|
|
93
|
+
back.add(mesh(slab(wall, 0.19, 0.012), shellDark, 0, 0.05));
|
|
94
|
+
|
|
95
|
+
const battery = part("5", 1, -0.42, 6, [0.62, 0.148, 0.26]);
|
|
96
|
+
battery.add(mesh(new RoundedBoxGeometry(1.5, 0.07, 0.8, 4, 0.03), new THREE.MeshStandardMaterial({ color: 0x3a63c9, metalness: 0.3, roughness: 0.45 }), 0, 0.11, 0.05));
|
|
97
|
+
|
|
98
|
+
const board = part("4", -1, 0.02, 5, [-1.3, 0.24, 0.48]);
|
|
99
|
+
board.add(mesh(new RoundedBoxGeometry(3.0, 0.035, 1.36, 2, 0.012), new THREE.MeshStandardMaterial({ map: boardTexture(), roughness: 0.55 }), 0, 0.22, 0));
|
|
100
|
+
for (const [x, z, size] of [[0.05, -0.08, 0.42], [0.5, -0.12, 0.26], [0.5, 0.28, 0.24], [-0.42, 0.3, 0.16]]) board.add(mesh(new RoundedBoxGeometry(size, 0.04, size, 2, 0.006), chip, x, 0.257, z));
|
|
101
|
+
|
|
102
|
+
const display = part("3", 1, 0.78, 4, [0.86, 0.336, 0.56]);
|
|
103
|
+
display.add(mesh(new RoundedBoxGeometry(1.86, 0.05, 1.28, 3, 0.02), metal, 0, 0.31, 0));
|
|
104
|
+
const panel = new THREE.Mesh(new THREE.PlaneGeometry(1.7, 1.12), new THREE.MeshStandardMaterial({ color: 0x000000, emissive: 0xe9805f, emissiveIntensity: 0.9, roughness: 0.25 }));
|
|
105
|
+
panel.rotation.x = -Math.PI / 2; panel.position.y = 0.337;
|
|
106
|
+
display.add(panel);
|
|
107
|
+
|
|
108
|
+
const keys = part("2", -1, 0.84, 3, [-1.5, 0.5, 0]);
|
|
109
|
+
keys.add(mesh(slab(cross(-1.25, 0, 0.27, 0.17), 0.12, 0.025), shellDark, 0, 0.38));
|
|
110
|
+
const cap = new THREE.LatheGeometry([[0, 0], [0.115, 0], [0.12, 0.05], [0.112, 0.085], [0.07, 0.1], [0, 0.103]].map(([x, y]) => new THREE.Vector2(x, y)), 40);
|
|
111
|
+
keys.add(mesh(cap, pbr(0xe2552b, 0.35, { clearcoat: 0.6 }), 1.38, 0.38, -0.08), mesh(cap, pbr(0x2f6f86, 0.35, { clearcoat: 0.6 }), 1.06, 0.38, 0.24));
|
|
112
|
+
|
|
113
|
+
const front = part("1", 1, 1.55, 2, [1.52, 0.44, 0.42]);
|
|
114
|
+
const face = rounded(W, D, R);
|
|
115
|
+
face.holes.push(rounded(1.86, 1.28, 0.08), cross(-1.25, 0, 0.3, 0.2), circle(1.38, 0.08, 0.13), circle(1.06, -0.24, 0.13));
|
|
116
|
+
front.add(mesh(slab(face, 0.08, 0.03), shell, 0, 0.36));
|
|
117
|
+
const lip = rounded(W, D, R); lip.holes.push(rounded(W - 0.12, D - 0.12, R - 0.06));
|
|
118
|
+
front.add(mesh(slab(lip, 0.09, 0.012), shell, 0, 0.27));
|
|
119
|
+
|
|
120
|
+
const product = new THREE.Group();
|
|
121
|
+
for (const p of parts) product.add(p.group);
|
|
122
|
+
scene.add(product);
|
|
123
|
+
const key = new THREE.DirectionalLight(0xfff4e8, 2.4);
|
|
124
|
+
key.position.set(-3.5, 7, 4);
|
|
125
|
+
key.castShadow = true;
|
|
126
|
+
key.shadow.mapSize.set(2048, 2048);
|
|
127
|
+
Object.assign(key.shadow.camera, { left: -4, right: 4, top: 4, bottom: -4, near: 1, far: 20 });
|
|
128
|
+
key.shadow.radius = 5;
|
|
129
|
+
key.shadow.normalBias = 0.02;
|
|
130
|
+
const ground = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), new THREE.ShadowMaterial({ opacity: 0.18 }));
|
|
131
|
+
ground.rotation.x = -Math.PI / 2;
|
|
132
|
+
ground.position.y = -2;
|
|
133
|
+
ground.receiveShadow = true;
|
|
134
|
+
scene.add(key, new THREE.DirectionalLight(0xdce8ff, 0.8).translateX(4).translateY(3).translateZ(-5), ground);
|
|
135
|
+
|
|
136
|
+
// 2D layers: the studio ground behind the transparent 3D, then leaders, numbered callouts and type on top.
|
|
137
|
+
const backdrop = new Graphics().rect(0, 0, 1920, 1080).fill(new FillGradient({ start: { x: 0, y: 0 }, end: { x: 0, y: 1 }, colorStops: [{ offset: 0, color: "#e6e7e8" }, { offset: 1, color: "#b3b7bb" }] }));
|
|
138
|
+
const leaders = new Graphics();
|
|
139
|
+
const badges = parts.map((p) => {
|
|
140
|
+
const number = txt(p.number, 26, { family: "mono", weight: "700", fill: 0xffffff });
|
|
141
|
+
number.anchor.set(0.5);
|
|
142
|
+
return number;
|
|
143
|
+
});
|
|
144
|
+
const name = txt(lines[2], 34, { family: "heading", weight: "700", fill: INK });
|
|
145
|
+
const title = txt(lines[0], 60, { family: "heading", weight: "700", fill: INK });
|
|
146
|
+
title.position.set(110, 92);
|
|
147
|
+
const subtitle = txt(lines[1], 26, { family: "mono", weight: "400", fill: 0x5f666d });
|
|
148
|
+
subtitle.position.set(114, 178);
|
|
149
|
+
root.addChild(backdrop, stage.sprite, leaders, ...badges, name, title, subtitle);
|
|
150
|
+
|
|
151
|
+
const TRAVEL = 1.25, CLOSE = 6.45;
|
|
152
|
+
const start = (p: Part) => 0.8 + p.order * 0.2;
|
|
153
|
+
const amount = (p: Part, t: number) => easeInOut(win(t, start(p), TRAVEL)) * (1 - easeInOut(win(t, CLOSE + (6 - p.order) * 0.05, 0.9)));
|
|
154
|
+
const point = new THREE.Vector3();
|
|
155
|
+
await stage.warm(scene, camera);
|
|
156
|
+
|
|
157
|
+
return (t) => {
|
|
158
|
+
let open = 0;
|
|
159
|
+
for (const p of parts) { const f = amount(p, t); open = Math.max(open, f); p.group.position.y = p.lift * f; }
|
|
160
|
+
const k = easeInOut(t / 8);
|
|
161
|
+
const azimuth = lerp(-0.6, 0.48, k), elevation = lerp(0.62, 0.5, k), distance = lerp(8.2, 12.6, easeInOut(open));
|
|
162
|
+
const center = lerp(0.22, 0.4, open);
|
|
163
|
+
camera.position.set(Math.sin(azimuth) * Math.cos(elevation) * distance, center + Math.sin(elevation) * distance, Math.cos(azimuth) * Math.cos(elevation) * distance);
|
|
164
|
+
camera.lookAt(0, center, 0);
|
|
165
|
+
product.updateMatrixWorld(true);
|
|
166
|
+
show(title, clamp01(t / 0.5));
|
|
167
|
+
show(subtitle, clamp01((t - 0.3) / 0.5));
|
|
168
|
+
|
|
169
|
+
// Callouts sit just outside the stack's silhouette on their side, kept a badge apart in anchor order.
|
|
170
|
+
const box = new THREE.Box3().setFromObject(product);
|
|
171
|
+
let minX = Infinity, maxX = -Infinity;
|
|
172
|
+
for (let i = 0; i < 8; i++) {
|
|
173
|
+
point.set(i & 1 ? box.max.x : box.min.x, i & 2 ? box.max.y : box.min.y, i & 4 ? box.max.z : box.min.z);
|
|
174
|
+
const s = stage.project(point, camera);
|
|
175
|
+
minX = Math.min(minX, s.x); maxX = Math.max(maxX, s.x);
|
|
176
|
+
}
|
|
177
|
+
const placed = parts.map((p, i) => {
|
|
178
|
+
point.copy(p.anchor).applyMatrix4(p.group.matrixWorld);
|
|
179
|
+
const a = stage.project(point, camera);
|
|
180
|
+
return { p, a, y: a.y, badge: badges[i], alpha: clamp01((t - start(p) - TRAVEL * 0.7) / 0.4) * (1 - clamp01((t - CLOSE + 0.35) / 0.3)) };
|
|
181
|
+
});
|
|
182
|
+
for (const side of [-1, 1]) {
|
|
183
|
+
const column = placed.filter((l) => l.p.side === side).sort((a, b) => a.a.y - b.a.y);
|
|
184
|
+
for (let pass = 0; pass < 6; pass++) {
|
|
185
|
+
for (let i = 1; i < column.length; i++) column[i].y = Math.max(column[i].y, column[i - 1].y + 86);
|
|
186
|
+
for (let i = column.length - 2; i >= 0; i--) column[i].y = Math.min(column[i].y, column[i + 1].y - 86);
|
|
187
|
+
for (const l of column) l.y = Math.min(Math.max(l.y, 260), 1010);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
leaders.clear();
|
|
191
|
+
show(name, 0);
|
|
192
|
+
for (const { p, a, y, badge, alpha } of placed) {
|
|
193
|
+
show(badge, alpha);
|
|
194
|
+
if (alpha <= 0.002) continue;
|
|
195
|
+
const x = p.side < 0 ? Math.min(minX - 70, a.x - 120) : Math.max(maxX + 70, a.x + 120);
|
|
196
|
+
leaders.moveTo(a.x, a.y).lineTo(x - p.side * 30, y).lineTo(x, y).stroke({ color: INK, width: 2.4, alpha: 0.8 * alpha });
|
|
197
|
+
leaders.circle(a.x, a.y, 8).fill({ color: 0xffffff, alpha }).stroke({ color: INK, width: 3, alpha });
|
|
198
|
+
leaders.circle(x + p.side * 26, y, 26).fill({ color: p.number === "5" ? ACCENT : INK, alpha });
|
|
199
|
+
badge.position.set(x + p.side * 26, y);
|
|
200
|
+
if (p.number === "5") placeName(name, x + p.side * 68, y, p.side, alpha);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
stage.draw(scene, camera);
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** The battery's name beside its callout, on the callout's side. */
|
|
208
|
+
function placeName(name: Text, x: number, y: number, side: -1 | 1, alpha: number) {
|
|
209
|
+
name.anchor.set(side < 0 ? 1 : 0, 0.5);
|
|
210
|
+
name.position.set(x, y);
|
|
211
|
+
show(name, alpha);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default study;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Container } from "pixi.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A 3D study builds its stage and Pixi layers into `root` (1920×1080 design space) and returns the frame update,
|
|
5
|
+
* a pure function of seconds that ends with stage.draw(). `lines` are its localized on-screen texts.
|
|
6
|
+
*/
|
|
7
|
+
export type ThreeStudy = (root: Container, lines: readonly string[]) => Promise<(seconds: number) => void>;
|
|
8
|
+
|
|
9
|
+
/** Loaded on demand, so a film only bundles the study it installs. */
|
|
10
|
+
export const THREE_STUDIES = {
|
|
11
|
+
"product-turntable-3d": () => import("./product-turntable-3d").then((m) => m.default),
|
|
12
|
+
"character-stage-3d": () => import("./character-stage-3d").then((m) => m.default),
|
|
13
|
+
"toy-diorama-3d": () => import("./toy-diorama-3d").then((m) => m.default),
|
|
14
|
+
"exploded-teardown-3d": () => import("./exploded-teardown-3d").then((m) => m.default),
|
|
15
|
+
} satisfies Record<string, () => Promise<ThreeStudy>>;
|
|
16
|
+
export type ThreeStudyId = keyof typeof THREE_STUDIES;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Container, Graphics } from "pixi.js";
|
|
2
|
+
import { THREE, ThreeStage, clamp01, easeInOut, easeOut, lerp, show, txt, vecAt, win } from "../../kit";
|
|
3
|
+
import type { ThreeStudy } from ".";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
3D Product Turntable study: an original product turned on a lathe in code (a ceramic body, a brushed metal band
|
|
7
|
+
and a dark cap) turns on a stone plinth in a dark studio. A macro on the band, a pull-back while the turntable
|
|
8
|
+
keeps turning, rack focus with a depth-of-field pass, a callout pinned to the band through the orbit, then a
|
|
9
|
+
push-in with the product on the right third and the title on the left. Replace the product with a supplied or
|
|
10
|
+
generated model (animaguru-3d-models) for a real film.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PLINTH = 0.16;
|
|
14
|
+
const INK = 0xf3ead8, ACCENT = 0xc89a62;
|
|
15
|
+
/** A constant turntable: 10° a second. */
|
|
16
|
+
const turn = (t: number) => (20 + 10 * t) * Math.PI / 180;
|
|
17
|
+
|
|
18
|
+
/** Radius by height for the lathe: a soft-shouldered body that narrows into a neck. */
|
|
19
|
+
function bodyProfile() {
|
|
20
|
+
const points: THREE.Vector2[] = [];
|
|
21
|
+
for (let i = 0; i <= 48; i++) {
|
|
22
|
+
const y = i / 48 * 0.78;
|
|
23
|
+
const shoulder = Math.max(0, (y - 0.58) / 0.2);
|
|
24
|
+
const r = 0.2 + 0.035 * Math.sin(Math.min(1, y / 0.58) * Math.PI) - 0.07 * shoulder * shoulder;
|
|
25
|
+
points.push(new THREE.Vector2(Math.max(0.105, r), y));
|
|
26
|
+
}
|
|
27
|
+
points.unshift(new THREE.Vector2(0, 0));
|
|
28
|
+
points.push(new THREE.Vector2(0, 0.78));
|
|
29
|
+
return points;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function backdrop() {
|
|
33
|
+
// A seamless sweep: near black above, a warm band at the horizon, ignoring fog so the floor fades into it.
|
|
34
|
+
return new THREE.Mesh(new THREE.SphereGeometry(16, 64, 32), new THREE.ShaderMaterial({
|
|
35
|
+
side: THREE.BackSide, depthWrite: false, fog: false,
|
|
36
|
+
uniforms: { top: { value: new THREE.Color(0x040303) }, horizon: { value: new THREE.Color(0x15110e) } },
|
|
37
|
+
vertexShader: "varying vec3 vDir; void main() { vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }",
|
|
38
|
+
fragmentShader: "uniform vec3 top; uniform vec3 horizon; varying vec3 vDir; void main() { gl_FragColor = vec4(mix(horizon, top, smoothstep(-0.05, 0.55, vDir.y)), 1.0); }",
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const study: ThreeStudy = async (root, lines) => {
|
|
43
|
+
const stage = new ThreeStage({ width: 1920, height: 1080, supersample: 1.5 });
|
|
44
|
+
stage.renderer.toneMappingExposure = 1;
|
|
45
|
+
const scene = new THREE.Scene();
|
|
46
|
+
scene.environment = stage.room();
|
|
47
|
+
scene.environmentIntensity = 0.75;
|
|
48
|
+
scene.fog = new THREE.Fog(0x15110e, 3, 9);
|
|
49
|
+
scene.add(backdrop());
|
|
50
|
+
|
|
51
|
+
const floor = new THREE.Mesh(new THREE.CircleGeometry(15, 96), new THREE.MeshStandardMaterial({ color: 0x0e0c0a, roughness: 0.75 }));
|
|
52
|
+
floor.rotation.x = -Math.PI / 2;
|
|
53
|
+
floor.receiveShadow = true;
|
|
54
|
+
const plinth = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.52, PLINTH, 128), new THREE.MeshPhysicalMaterial({ color: 0x14110f, roughness: 0.4, clearcoat: 0.5, clearcoatRoughness: 0.25 }));
|
|
55
|
+
plinth.position.y = PLINTH / 2;
|
|
56
|
+
plinth.castShadow = plinth.receiveShadow = true;
|
|
57
|
+
scene.add(floor, plinth);
|
|
58
|
+
|
|
59
|
+
// The product: ceramic body, brushed band, dark cap. All parts turn together on the plinth.
|
|
60
|
+
const product = new THREE.Group();
|
|
61
|
+
product.position.y = PLINTH;
|
|
62
|
+
const body = new THREE.Mesh(new THREE.LatheGeometry(bodyProfile(), 128), new THREE.MeshPhysicalMaterial({ color: 0xd9cfbf, roughness: 0.38, clearcoat: 0.5, clearcoatRoughness: 0.3 }));
|
|
63
|
+
const band = new THREE.Mesh(new THREE.CylinderGeometry(0.236, 0.236, 0.07, 128, 1, true), new THREE.MeshPhysicalMaterial({ color: 0xd6d9dd, metalness: 1, roughness: 0.16, side: THREE.DoubleSide }));
|
|
64
|
+
band.position.y = 0.3;
|
|
65
|
+
const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.098, 0.11, 0.16, 96), new THREE.MeshPhysicalMaterial({ color: 0x2a2826, metalness: 0.85, roughness: 0.32 }));
|
|
66
|
+
cap.position.y = 0.86;
|
|
67
|
+
const seam = new THREE.Mesh(new THREE.TorusGeometry(0.106, 0.006, 12, 96), new THREE.MeshStandardMaterial({ color: 0xc89a62, metalness: 1, roughness: 0.25 }));
|
|
68
|
+
seam.rotation.x = Math.PI / 2;
|
|
69
|
+
seam.position.y = 0.785;
|
|
70
|
+
for (const part of [body, band, cap, seam]) { part.castShadow = part.receiveShadow = true; product.add(part); }
|
|
71
|
+
scene.add(product);
|
|
72
|
+
|
|
73
|
+
const key = new THREE.SpotLight(0xfff0de, 22, 12, 0.42, 0.8, 1.6);
|
|
74
|
+
key.position.set(-1.7, 3, 2.2);
|
|
75
|
+
key.target.position.set(0, 0.55, 0);
|
|
76
|
+
key.castShadow = true;
|
|
77
|
+
key.shadow.mapSize.set(2048, 2048);
|
|
78
|
+
key.shadow.radius = 6;
|
|
79
|
+
key.shadow.bias = -0.0003;
|
|
80
|
+
const rim = new THREE.SpotLight(0xffb070, 22, 10, 0.34, 0.8, 1.5);
|
|
81
|
+
rim.position.set(1.8, 2.5, -2.2);
|
|
82
|
+
rim.target.position.set(0, 0.7, 0);
|
|
83
|
+
scene.add(key, key.target, rim, rim.target);
|
|
84
|
+
|
|
85
|
+
const camera = new THREE.PerspectiveCamera(28, 1920 / 1080, 0.02, 60);
|
|
86
|
+
const depth = stage.post(scene, camera, { bokeh: { focus: 1.5, aperture: 0.005, maxblur: 0.008 } })!;
|
|
87
|
+
const focusUniforms = depth.uniforms as Record<string, { value: number }>;
|
|
88
|
+
|
|
89
|
+
// 2D layers: an eyebrow, a callout on the band, and the closing title.
|
|
90
|
+
const overlay = new Container();
|
|
91
|
+
const eyebrow = txt(lines[1].toUpperCase(), 26, { family: "mono", fill: 0xb7aa96 });
|
|
92
|
+
eyebrow.position.set(120, 96);
|
|
93
|
+
const leader = new Graphics();
|
|
94
|
+
const label = txt(lines[2], 40, { family: "heading", weight: "600", fill: INK });
|
|
95
|
+
const title = txt(lines[0], 84, { family: "heading", weight: "700", fill: INK, wordWrap: true, wordWrapWidth: 760 });
|
|
96
|
+
title.position.set(120, 420);
|
|
97
|
+
const rule = new Graphics();
|
|
98
|
+
overlay.addChild(eyebrow, leader, label, title, rule);
|
|
99
|
+
root.addChild(stage.sprite, overlay);
|
|
100
|
+
|
|
101
|
+
const centre = new THREE.Vector3(), target = new THREE.Vector3(), focus = new THREE.Vector3(), offset = new THREE.Vector3(), right = new THREE.Vector3();
|
|
102
|
+
const anchor = new THREE.Vector3(), toward = new THREE.Vector3();
|
|
103
|
+
/** The band point nearest the camera: the ring is round, so the callout stays on its front while the product turns. */
|
|
104
|
+
const bandPoint = (out: THREE.Vector3) => {
|
|
105
|
+
toward.set(camera.position.x, 0, camera.position.z).normalize();
|
|
106
|
+
return out.set(toward.x * 0.236, PLINTH + 0.3, toward.z * 0.236);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function pose(t: number) {
|
|
110
|
+
product.rotation.y = turn(t);
|
|
111
|
+
centre.set(0, PLINTH + 0.45, 0);
|
|
112
|
+
const [az, el, dist] = vecAt(t, [[0, [34, 6, 1.05]], [1.6, [26, 8, 1.15]], [3.6, [6, 13, 2.6]], [6, [-8, 15, 2.5]], [8, [-14, 16, 2.25]]], offset).toArray();
|
|
113
|
+
const macro = 1 - easeInOut(win(t, 1.4, 2.1));
|
|
114
|
+
target.set(0, PLINTH + 0.3, 0.2).lerp(centre, 1 - macro);
|
|
115
|
+
const a = az * Math.PI / 180, e = el * Math.PI / 180;
|
|
116
|
+
camera.position.set(target.x + dist * Math.cos(e) * Math.sin(a), target.y + dist * Math.sin(e), target.z + dist * Math.cos(e) * Math.cos(a));
|
|
117
|
+
camera.lookAt(target);
|
|
118
|
+
camera.updateMatrixWorld(true);
|
|
119
|
+
// The closing push-in slides the product to the right third.
|
|
120
|
+
const slide = easeInOut(win(t, 5, 1.5));
|
|
121
|
+
right.setFromMatrixColumn(camera.matrixWorld, 0);
|
|
122
|
+
camera.position.addScaledVector(right, -0.32 * slide);
|
|
123
|
+
target.addScaledVector(right, -0.32 * slide);
|
|
124
|
+
camera.lookAt(target);
|
|
125
|
+
// Rack focus: the band, then the cap, then the whole product.
|
|
126
|
+
focus.copy(bandPoint(anchor)).lerp(cap.getWorldPosition(new THREE.Vector3()), easeInOut(win(t, 2.6, 0.8))).lerp(centre, easeInOut(win(t, 4.6, 0.8)));
|
|
127
|
+
focusUniforms.focus.value = camera.position.distanceTo(focus);
|
|
128
|
+
focusUniforms.aperture.value = lerp(0.005, 0.002, easeInOut(win(t, 1.4, 2.2)));
|
|
129
|
+
// Reflections slide across the band and cap as the studio turns.
|
|
130
|
+
scene.environmentRotation.set(0, lerp(-0.6, 0.6, t / 8), 0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function layers(t: number) {
|
|
134
|
+
show(eyebrow, clamp01(t / 0.5) * (1 - clamp01((t - 4.8) / 0.4)));
|
|
135
|
+
// The callout rides the band's front through the orbit, then gives way to the title.
|
|
136
|
+
const on = easeOut(win(t, 0.5, 0.5)) * (1 - easeOut(win(t, 4.2, 0.4)));
|
|
137
|
+
const p = stage.project(bandPoint(anchor), camera);
|
|
138
|
+
const draw = easeOut(win(t, 0.5, 0.6));
|
|
139
|
+
const elbow = { x: p.x + 90 * Math.min(1, draw * 2), y: p.y - 90 * Math.min(1, draw * 2) };
|
|
140
|
+
const end = { x: p.x + 90 + 230 * clamp01(draw * 2 - 1), y: p.y - 90 };
|
|
141
|
+
leader.clear().moveTo(p.x, p.y).lineTo(elbow.x, elbow.y).lineTo(end.x, end.y).stroke({ color: INK, width: 2.5 }).circle(p.x, p.y, 7).fill(INK);
|
|
142
|
+
show(leader, on);
|
|
143
|
+
label.position.set(p.x + 100, p.y - 146);
|
|
144
|
+
show(label, on * clamp01((t - 0.8) / 0.4));
|
|
145
|
+
const lockup = easeOut(win(t, 6, 0.8));
|
|
146
|
+
show(title, lockup);
|
|
147
|
+
title.x = 120 - 30 * (1 - lockup);
|
|
148
|
+
rule.clear().rect(122, 620, 420 * easeOut(win(t, 6.3, 0.9)), 3).fill(ACCENT);
|
|
149
|
+
show(rule, lockup);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pose(0);
|
|
153
|
+
await stage.warm(scene, camera);
|
|
154
|
+
return (t) => {
|
|
155
|
+
pose(t);
|
|
156
|
+
layers(t);
|
|
157
|
+
stage.draw(scene, camera);
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
export default study;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { Graphics } from "pixi.js";
|
|
2
|
+
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
|
|
3
|
+
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
|
|
4
|
+
import { THREE, ThreeStage, clamp01, inOut, lerp, rng, show, txt } from "../../kit";
|
|
5
|
+
import type { ThreeStudy } from ".";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
3D Toy Diorama study: a block-toy harbour town on a round birch base, built in code. Cars run a road loop and
|
|
9
|
+
boats a bay loop, each a CatmullRom curve sampled by film time; the camera glides along its own curve; a
|
|
10
|
+
separable tilt-shift pass keeps one band sharp around the point the camera looks at. A production film swaps
|
|
11
|
+
the blocks for generated toy models (animaguru-3d-models) placed on the same base and paths.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const R = 3.3, WATER = 0.15, LAND = 0.27, TABLE = -0.24;
|
|
15
|
+
/** The bay's shoreline: land behind it (smaller z), water in front. */
|
|
16
|
+
const shore = (x: number) => 0.25 + 0.17 * x * x;
|
|
17
|
+
|
|
18
|
+
const vertex = "varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }";
|
|
19
|
+
/** One direction of the tilt-shift blur: sharp inside a band around `center` (0 bottom, 1 top), up to `amount` pixels outside it. */
|
|
20
|
+
const tiltShift = (direction: THREE.Vector2) => ({
|
|
21
|
+
uniforms: { tDiffuse: { value: null }, center: { value: 0.5 }, band: { value: 0.07 }, falloff: { value: 0.32 }, amount: { value: 22 }, texel: { value: new THREE.Vector2() }, direction: { value: direction } },
|
|
22
|
+
vertexShader: vertex,
|
|
23
|
+
fragmentShader: `
|
|
24
|
+
uniform sampler2D tDiffuse; uniform float center, band, falloff, amount; uniform vec2 texel, direction; varying vec2 vUv;
|
|
25
|
+
void main() {
|
|
26
|
+
float d = smoothstep(band, band + falloff, abs(vUv.y - center));
|
|
27
|
+
float radius = amount * d * d;
|
|
28
|
+
vec4 sum = vec4(0.0); float weight = 0.0;
|
|
29
|
+
for (int i = -7; i <= 7; i++) { float k = float(i) / 7.0, w = exp(-3.0 * k * k); sum += texture2D(tDiffuse, vUv + direction * texel * radius * k) * w; weight += w; }
|
|
30
|
+
gl_FragColor = sum / weight;
|
|
31
|
+
}`,
|
|
32
|
+
});
|
|
33
|
+
/** Toy-photo grade before tone mapping: more saturation, a warm cast. */
|
|
34
|
+
const grade = {
|
|
35
|
+
uniforms: { tDiffuse: { value: null } },
|
|
36
|
+
vertexShader: vertex,
|
|
37
|
+
fragmentShader: "uniform sampler2D tDiffuse; varying vec2 vUv; void main() { vec4 c = texture2D(tDiffuse, vUv); float l = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); c.rgb = mix(vec3(l), c.rgb, 1.2) * vec3(1.05, 1.0, 0.93); gl_FragColor = c; }",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** A flat ribbon along a curve (road, promenade). */
|
|
41
|
+
function ribbon(curve: THREE.Curve<THREE.Vector3>, width: number, y: number, samples = 300) {
|
|
42
|
+
const positions: number[] = [], index: number[] = [];
|
|
43
|
+
for (let i = 0; i <= samples; i++) {
|
|
44
|
+
const u = (i / samples) % 1, p = curve.getPointAt(u), tangent = curve.getTangentAt(u);
|
|
45
|
+
const side = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize().multiplyScalar(width / 2);
|
|
46
|
+
positions.push(p.x - side.x, y, p.z - side.z, p.x + side.x, y, p.z + side.z);
|
|
47
|
+
if (i < samples) { const a = i * 2; index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }
|
|
48
|
+
}
|
|
49
|
+
const geometry = new THREE.BufferGeometry();
|
|
50
|
+
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
|
51
|
+
geometry.setIndex(index);
|
|
52
|
+
geometry.computeVertexNormals();
|
|
53
|
+
return geometry;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The land behind the shoreline, extruded up from the base; its sides are the quay wall. */
|
|
57
|
+
function land() {
|
|
58
|
+
let edge = 0;
|
|
59
|
+
for (let x = 0; x < R; x += 0.001) if (x * x + shore(x) ** 2 > R * R) { edge = x; break; }
|
|
60
|
+
const shape = new THREE.Shape(), steps = 100;
|
|
61
|
+
for (let i = 0; i <= steps; i++) { const x = lerp(-edge, edge, i / steps); if (i) shape.lineTo(x, -shore(x)); else shape.moveTo(x, -shore(x)); }
|
|
62
|
+
const a0 = Math.atan2(-shore(edge), edge), a1 = Math.atan2(-shore(-edge), -edge) + Math.PI * 2;
|
|
63
|
+
for (let i = 1; i <= steps; i++) { const a = lerp(a0, a1, i / steps); shape.lineTo(Math.cos(a) * R, Math.sin(a) * R); }
|
|
64
|
+
const geometry = new THREE.ExtrudeGeometry(shape, { depth: LAND - WATER + 0.03, bevelEnabled: true, bevelThickness: 0.012, bevelSize: 0.012, bevelSegments: 3, curveSegments: 48 });
|
|
65
|
+
geometry.rotateX(-Math.PI / 2);
|
|
66
|
+
// The bevel adds thickness on both faces: put the top exactly at land level.
|
|
67
|
+
geometry.computeBoundingBox();
|
|
68
|
+
geometry.translate(0, LAND - geometry.boundingBox!.max.y, 0);
|
|
69
|
+
return geometry;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const study: ThreeStudy = async (root, lines) => {
|
|
73
|
+
const stage = new ThreeStage({ width: 1920, height: 1080, supersample: 1.5 });
|
|
74
|
+
const scene = new THREE.Scene();
|
|
75
|
+
const room = new THREE.Color(0x2b2119);
|
|
76
|
+
scene.background = room;
|
|
77
|
+
scene.fog = new THREE.Fog(room, 11, 22);
|
|
78
|
+
scene.environment = stage.room(0.05);
|
|
79
|
+
scene.environmentIntensity = 0.45;
|
|
80
|
+
const camera = new THREE.PerspectiveCamera(30, 1920 / 1080, 0.1, 60);
|
|
81
|
+
const sun = new THREE.DirectionalLight(0xffe2b8, 3.2);
|
|
82
|
+
sun.position.set(-5.5, 7, 3.5);
|
|
83
|
+
sun.castShadow = true;
|
|
84
|
+
sun.shadow.mapSize.set(2048, 2048);
|
|
85
|
+
Object.assign(sun.shadow.camera, { left: -4.2, right: 4.2, top: 4.2, bottom: -4.2, near: 1, far: 20 });
|
|
86
|
+
sun.shadow.radius = 3;
|
|
87
|
+
sun.shadow.bias = -0.0004;
|
|
88
|
+
scene.add(sun, new THREE.HemisphereLight(0xcfe0ff, 0x7a5a3a, 0.9));
|
|
89
|
+
|
|
90
|
+
const paint = (color: number, roughness = 0.42) => new THREE.MeshStandardMaterial({ color, roughness });
|
|
91
|
+
const add = <T extends THREE.Mesh>(mesh: T) => { mesh.castShadow = mesh.receiveShadow = true; scene.add(mesh); return mesh; };
|
|
92
|
+
const table = add(new THREE.Mesh(new THREE.PlaneGeometry(40, 40), paint(0x7a4e2c, 0.65)));
|
|
93
|
+
table.rotation.x = -Math.PI / 2;
|
|
94
|
+
table.position.y = TABLE;
|
|
95
|
+
add(new THREE.Mesh(new THREE.LatheGeometry([new THREE.Vector2(0, TABLE), new THREE.Vector2(R + 0.02, TABLE), new THREE.Vector2(R + 0.09, TABLE + 0.04), new THREE.Vector2(R + 0.1, WATER - 0.05), new THREE.Vector2(R + 0.06, WATER - 0.01), new THREE.Vector2(0, WATER - 0.01)], 96), paint(0xdcbc8f, 0.5)));
|
|
96
|
+
const water = add(new THREE.Mesh(new THREE.CircleGeometry(R + 0.02, 96), new THREE.MeshPhysicalMaterial({ color: 0x2d8fa3, roughness: 0.1, clearcoat: 1, clearcoatRoughness: 0.05 })));
|
|
97
|
+
water.rotation.x = -Math.PI / 2;
|
|
98
|
+
water.position.y = WATER;
|
|
99
|
+
add(new THREE.Mesh(land(), [paint(0x9fc27a, 0.85), paint(0xbfb39b, 0.8)]));
|
|
100
|
+
const promenade = new THREE.CatmullRomCurve3(Array.from({ length: 9 }, (_, i) => { const x = lerp(-2.2, 2.2, i / 8); return new THREE.Vector3(x, 0, shore(x) - 0.27); }));
|
|
101
|
+
add(new THREE.Mesh(ribbon(promenade, 0.3, LAND + 0.002), paint(0xe6dcc6, 0.8)));
|
|
102
|
+
const road = new THREE.CatmullRomCurve3([
|
|
103
|
+
...Array.from({ length: 9 }, (_, i) => { const x = lerp(-2.2, 2.2, i / 8); return new THREE.Vector3(x, 0, shore(x) - 0.62); }),
|
|
104
|
+
new THREE.Vector3(2.6, 0, -0.55), new THREE.Vector3(2.05, 0, -1.75), new THREE.Vector3(0.8, 0, -2.45), new THREE.Vector3(-0.8, 0, -2.45), new THREE.Vector3(-2.05, 0, -1.75), new THREE.Vector3(-2.6, 0, -0.55),
|
|
105
|
+
], true, "centripetal");
|
|
106
|
+
add(new THREE.Mesh(ribbon(road, 0.34, LAND + 0.004), paint(0x5a6470, 0.7)));
|
|
107
|
+
|
|
108
|
+
// Block-toy houses with pitched roofs, a tower, a lighthouse and lollipop trees.
|
|
109
|
+
const next = rng(11);
|
|
110
|
+
const house = (x: number, z: number, w: number, h: number, wall: number, roof: number, yaw: number) => {
|
|
111
|
+
const group = new THREE.Group();
|
|
112
|
+
const body = new THREE.Mesh(new RoundedBoxGeometry(w, h, 0.34, 3, 0.025), paint(wall));
|
|
113
|
+
body.position.y = h / 2;
|
|
114
|
+
// A pitched roof: a triangle extruded across the house's width.
|
|
115
|
+
const gable = new THREE.Shape([new THREE.Vector2(-0.21, 0), new THREE.Vector2(0.21, 0), new THREE.Vector2(0, 0.2)]);
|
|
116
|
+
const roofGeometry = new THREE.ExtrudeGeometry(gable, { depth: w + 0.04, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 2 });
|
|
117
|
+
roofGeometry.translate(0, 0, -(w + 0.04) / 2);
|
|
118
|
+
roofGeometry.rotateY(Math.PI / 2);
|
|
119
|
+
const top = new THREE.Mesh(roofGeometry, paint(roof, 0.5));
|
|
120
|
+
top.position.y = h - 0.005;
|
|
121
|
+
for (const side of [-1, 1]) {
|
|
122
|
+
const window = new THREE.Mesh(new RoundedBoxGeometry(0.08, 0.08, 0.02, 2, 0.008), paint(0xfff6e0, 0.3));
|
|
123
|
+
window.position.set(side * w * 0.24, h * 0.68, 0.175);
|
|
124
|
+
window.castShadow = true;
|
|
125
|
+
group.add(window);
|
|
126
|
+
}
|
|
127
|
+
const door = new THREE.Mesh(new RoundedBoxGeometry(0.08, 0.14, 0.02, 2, 0.01), paint(0x2f3b59));
|
|
128
|
+
door.position.set(0, 0.07, 0.175);
|
|
129
|
+
for (const mesh of [body, top, door]) { mesh.castShadow = mesh.receiveShadow = true; group.add(mesh); }
|
|
130
|
+
group.position.set(x, LAND, z);
|
|
131
|
+
group.rotation.y = yaw;
|
|
132
|
+
scene.add(group);
|
|
133
|
+
};
|
|
134
|
+
const colours = [0xe8705f, 0xf2c14e, 0x6fa8dc, 0xf3e3c3, 0x8fcfb3, 0xe58fb0];
|
|
135
|
+
for (let i = 0; i < 9; i++) {
|
|
136
|
+
const x = lerp(-2.15, 2.15, i / 8), z = -0.85 - Math.abs(x) * 0.08;
|
|
137
|
+
house(x, z, 0.38 + next() * 0.12, 0.38 + next() * 0.3, colours[i % colours.length], i % 2 ? 0xc0553a : 0xa64230, -x * 0.12);
|
|
138
|
+
}
|
|
139
|
+
const tower = add(new THREE.Mesh(new RoundedBoxGeometry(0.42, 1.05, 0.42, 3, 0.03), paint(0xf3e3c3)));
|
|
140
|
+
tower.position.set(-0.1, LAND + 0.525, -1.75);
|
|
141
|
+
const spire = add(new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.45, 4), paint(0x3a9c9a)));
|
|
142
|
+
spire.position.set(-0.1, LAND + 1.27, -1.75);
|
|
143
|
+
spire.rotation.y = Math.PI / 4;
|
|
144
|
+
for (let i = 0; i < 4; i++) {
|
|
145
|
+
const band = add(new THREE.Mesh(new THREE.CylinderGeometry(0.17 - i * 0.012, 0.18 - i * 0.012, 0.22, 32), paint(i % 2 ? 0xf6f1e7 : 0xd7473b)));
|
|
146
|
+
band.position.set(-2.72, LAND + 0.11 + i * 0.22, 1.25);
|
|
147
|
+
}
|
|
148
|
+
const lantern = add(new THREE.Mesh(new THREE.SphereGeometry(0.13, 24, 16), new THREE.MeshStandardMaterial({ color: 0xfff3d6, emissive: 0xffc46b, emissiveIntensity: 1.6 })));
|
|
149
|
+
lantern.position.set(-2.72, LAND + 1.0, 1.25);
|
|
150
|
+
for (let i = 0; i < 16; i++) {
|
|
151
|
+
const a = (i / 16) * Math.PI * 2, rr = 2.45 + next() * 0.6, x = Math.cos(a) * rr, z = Math.min(Math.sin(a) * rr, -1.25 - next() * 0.4);
|
|
152
|
+
if (Math.abs(x) > 2.9 && z > -1) continue;
|
|
153
|
+
const trunk = add(new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.03, 0.18, 12), paint(0x8a5a35)));
|
|
154
|
+
trunk.position.set(x, LAND + 0.09, z);
|
|
155
|
+
const crown = add(new THREE.Mesh(new THREE.SphereGeometry(0.15 + next() * 0.06, 24, 16), paint(i % 3 ? 0x5fae5a : 0x3f8f4f, 0.55)));
|
|
156
|
+
crown.position.set(x, LAND + 0.3, z);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Cars on the road loop, boats on the bay.
|
|
160
|
+
const vehicle = (colour: number, length: number) => {
|
|
161
|
+
const group = new THREE.Group();
|
|
162
|
+
const body = new THREE.Mesh(new RoundedBoxGeometry(0.15, 0.09, length, 3, 0.035), paint(colour, 0.3));
|
|
163
|
+
body.position.y = 0.075;
|
|
164
|
+
const cabin = new THREE.Mesh(new RoundedBoxGeometry(0.12, 0.07, length * 0.5, 3, 0.03), paint(0xf6f1e7, 0.35));
|
|
165
|
+
cabin.position.set(0, 0.145, -length * 0.08);
|
|
166
|
+
group.add(body, cabin);
|
|
167
|
+
for (const [x, z] of [[-0.075, length * 0.32], [0.075, length * 0.32], [-0.075, -length * 0.32], [0.075, -length * 0.32]]) {
|
|
168
|
+
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.03, 20), paint(0x1d2327, 0.6));
|
|
169
|
+
wheel.rotation.z = Math.PI / 2;
|
|
170
|
+
wheel.position.set(x, 0.035, z);
|
|
171
|
+
group.add(wheel);
|
|
172
|
+
}
|
|
173
|
+
group.traverse((node) => { if ((node as THREE.Mesh).isMesh) node.castShadow = true; });
|
|
174
|
+
scene.add(group);
|
|
175
|
+
return group;
|
|
176
|
+
};
|
|
177
|
+
const car = vehicle(0xd23b30, 0.3), van = vehicle(0xf2c14e, 0.36);
|
|
178
|
+
const boat = (hull: number, sail: boolean) => {
|
|
179
|
+
const group = new THREE.Group();
|
|
180
|
+
const body = new THREE.Mesh(new RoundedBoxGeometry(0.2, 0.1, 0.5, 3, 0.045), paint(hull, 0.3));
|
|
181
|
+
body.position.y = 0.04;
|
|
182
|
+
group.add(body);
|
|
183
|
+
const top = sail ? new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.42, 3), paint(0xf6efe0, 0.6)) : new THREE.Mesh(new RoundedBoxGeometry(0.14, 0.12, 0.16, 2, 0.02), paint(0xf6f1e7));
|
|
184
|
+
top.position.set(0, sail ? 0.3 : 0.15, sail ? 0.02 : -0.06);
|
|
185
|
+
if (sail) top.scale.set(0.25, 1, 1);
|
|
186
|
+
group.add(top);
|
|
187
|
+
group.traverse((node) => { if ((node as THREE.Mesh).isMesh) node.castShadow = true; });
|
|
188
|
+
scene.add(group);
|
|
189
|
+
return group;
|
|
190
|
+
};
|
|
191
|
+
const fishing = boat(0x2f4f8f, false), sailing = boat(0xf6f1e7, true);
|
|
192
|
+
const bay = new THREE.CatmullRomCurve3(Array.from({ length: 12 }, (_, i) => { const a = (i / 12) * Math.PI * 2; return new THREE.Vector3(Math.cos(a) * 1.45, 0, 1.75 + Math.sin(a) * 0.62); }), true);
|
|
193
|
+
const drive = (object: THREE.Object3D, curve: THREE.Curve<THREE.Vector3>, u: number, y: number) => {
|
|
194
|
+
const at = ((u % 1) + 1) % 1, p = curve.getPointAt(at), tangent = curve.getTangentAt(at);
|
|
195
|
+
object.position.set(p.x, y, p.z);
|
|
196
|
+
object.rotation.y = Math.atan2(tangent.x, tangent.z);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const cameraPath = new THREE.CatmullRomCurve3([new THREE.Vector3(-6.2, 5.6, 9.4), new THREE.Vector3(-2.6, 4.8, 9.2), new THREE.Vector3(1.2, 4.1, 8.6), new THREE.Vector3(4.2, 3.6, 7.2)]);
|
|
200
|
+
const targetPath = new THREE.CatmullRomCurve3([new THREE.Vector3(-1.2, 0.35, 0.15), new THREE.Vector3(-0.3, 0.32, 0.0), new THREE.Vector3(0.55, 0.3, -0.2), new THREE.Vector3(1.05, 0.3, -0.42)]);
|
|
201
|
+
const tiltH = new ShaderPass(tiltShift(new THREE.Vector2(1, 0))), tiltV = new ShaderPass(tiltShift(new THREE.Vector2(0, 1)));
|
|
202
|
+
for (const pass of [tiltH, tiltV]) pass.uniforms.texel.value.set(1 / (1920 * 1.5), 1 / (1080 * 1.5));
|
|
203
|
+
stage.post(scene, camera, { passes: [new ShaderPass(grade), tiltH, tiltV] });
|
|
204
|
+
|
|
205
|
+
// Type: a title, a bracket marking the sharp band, and a label riding the red car.
|
|
206
|
+
// A soft dark shadow keeps the light type readable over pale houses and water.
|
|
207
|
+
const shadow = { color: 0x2b2119, alpha: 0.55, blur: 8, distance: 0, angle: 0 };
|
|
208
|
+
const title = txt(lines[0], 64, { family: "heading", weight: "700", fill: 0xfff6e8, dropShadow: shadow });
|
|
209
|
+
title.position.set(110, 92);
|
|
210
|
+
const bandLabel = txt(lines[1], 30, { family: "heading", weight: "700", fill: 0xfff6e8, dropShadow: shadow });
|
|
211
|
+
const carLabel = txt(lines[2], 30, { family: "heading", weight: "700", fill: 0xfff6e8, dropShadow: shadow });
|
|
212
|
+
const marks = new Graphics();
|
|
213
|
+
root.addChild(stage.sprite, marks, title, bandLabel, carLabel);
|
|
214
|
+
const look = new THREE.Vector3();
|
|
215
|
+
|
|
216
|
+
const update = (t: number) => {
|
|
217
|
+
const u = 0.5 - 0.5 * Math.cos(Math.PI * clamp01(t / 8));
|
|
218
|
+
camera.position.copy(cameraPath.getPoint(u));
|
|
219
|
+
camera.lookAt(targetPath.getPoint(u, look));
|
|
220
|
+
drive(car, road, 0.9 + t * 0.05, LAND + 0.004);
|
|
221
|
+
drive(van, road, 0.12 + t * 0.03, LAND + 0.004);
|
|
222
|
+
drive(fishing, bay, 0.18 + t * 0.022, WATER - 0.02 + Math.sin(t * 2.1) * 0.006);
|
|
223
|
+
drive(sailing, bay, 0.68 + t * 0.016, WATER - 0.02 + Math.sin(t * 1.6 + 1) * 0.006);
|
|
224
|
+
const focus = stage.project(look, camera);
|
|
225
|
+
const center = Math.min(0.8, Math.max(0.2, 1 - focus.y / 1080));
|
|
226
|
+
for (const pass of [tiltH, tiltV]) pass.uniforms.center.value = center;
|
|
227
|
+
// The bracket spans the sharp band on the left edge of the frame; a leader rides the red car.
|
|
228
|
+
const top = (1 - center - 0.07) * 1080, bottom = (1 - center + 0.07) * 1080;
|
|
229
|
+
const bandAlpha = inOut(t, 1.6, 7.6);
|
|
230
|
+
marks.clear().moveTo(84, top).lineTo(64, top).lineTo(64, bottom).lineTo(84, bottom).stroke({ color: 0xfff6e8, width: 3, alpha: bandAlpha });
|
|
231
|
+
bandLabel.position.set(100, (top + bottom) / 2 - 20);
|
|
232
|
+
show(bandLabel, bandAlpha);
|
|
233
|
+
const c = stage.project(car.position.clone().add(new THREE.Vector3(0, 0.22, 0)), camera);
|
|
234
|
+
const carAlpha = inOut(t, 3.2, 7.6) * (c.behind || c.x < 40 || c.x > 1560 || c.y < 200 || c.y > 1000 ? 0 : 1);
|
|
235
|
+
if (carAlpha > 0) marks.moveTo(c.x, c.y).lineTo(c.x + 60, c.y - 70).lineTo(c.x + 110, c.y - 70).stroke({ color: 0xfff6e8, width: 3, alpha: carAlpha }).circle(c.x, c.y, 6).fill({ color: 0xfff6e8, alpha: carAlpha });
|
|
236
|
+
carLabel.position.set(c.x + 122, c.y - 92);
|
|
237
|
+
show(carLabel, carAlpha);
|
|
238
|
+
show(title, inOut(t, 0.4, 7.6));
|
|
239
|
+
stage.draw(scene, camera);
|
|
240
|
+
};
|
|
241
|
+
update(0);
|
|
242
|
+
await stage.warm(scene, camera);
|
|
243
|
+
return update;
|
|
244
|
+
};
|
|
245
|
+
export default study;
|