Remotion: Gitter-Verformung (three.js), Federn, VerformTest, vorberechnete Hintergründe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
46ffe5c52c
commit
7c120597d9
12 changed files with 449 additions and 15 deletions
67
remotion/package-lock.json
generated
67
remotion/package-lock.json
generated
|
|
@ -10,10 +10,12 @@
|
|||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"remotion": "^4.0.300",
|
||||
"three": "^0.169.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12"
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/three": "^0.169.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
|
|
@ -807,6 +809,13 @@
|
|||
"zod": "3.22.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
|
|
@ -866,6 +875,35 @@
|
|||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.169.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz",
|
||||
"integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
|
|
@ -1022,6 +1060,13 @@
|
|||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.74",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xtuc/ieee754": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
|
|
@ -1575,6 +1620,13 @@
|
|||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-monkey": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz",
|
||||
|
|
@ -1802,6 +1854,13 @@
|
|||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
|
||||
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
|
|
@ -2625,6 +2684,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.169.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz",
|
||||
"integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"remotion": "^4.0.300",
|
||||
"three": "^0.169.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12"
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/three": "^0.169.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 295 KiB |
BIN
remotion/public/hintergrund/abend.png
Normal file
BIN
remotion/public/hintergrund/abend.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 632 KiB |
BIN
remotion/public/hintergrund/papier.png
Normal file
BIN
remotion/public/hintergrund/papier.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
BIN
remotion/public/hintergrund/vordergrund.png
Normal file
BIN
remotion/public/hintergrund/vordergrund.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
|
|
@ -1,14 +1,17 @@
|
|||
import React from 'react';
|
||||
import {Composition} from 'remotion';
|
||||
import {HintergrundAbend, PapierStruktur, VordergrundAbend} from './szenen/Hintergruende';
|
||||
import {LaterneTest} from './szenen/LaterneTest';
|
||||
import {VerformTest} from './szenen/VerformTest';
|
||||
|
||||
const BILD = {fps: 30, width: 1920, height: 1080} as const;
|
||||
|
||||
export const Root: React.FC = () => (
|
||||
<Composition
|
||||
id="LaterneTest"
|
||||
component={LaterneTest}
|
||||
durationInFrames={420}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
<>
|
||||
<Composition id="LaterneTest" component={LaterneTest} durationInFrames={420} {...BILD} />
|
||||
<Composition id="VerformTest" component={VerformTest} durationInFrames={180} {...BILD} />
|
||||
<Composition id="HintergrundAbend" component={HintergrundAbend} durationInFrames={1} {...BILD} />
|
||||
<Composition id="VordergrundAbend" component={VordergrundAbend} durationInFrames={1} {...BILD} />
|
||||
<Composition id="PapierStruktur" component={PapierStruktur} durationInFrames={1} {...BILD} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
43
remotion/src/bewegung.ts
Normal file
43
remotion/src/bewegung.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Hilfen für weiche Bewegung: Kurven, Federn, Gewichte für die Verformung.
|
||||
|
||||
export const klemm01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||
|
||||
// Weicher Übergang 0 → 1 zwischen a und b (a > b ist erlaubt und kehrt die Richtung um).
|
||||
export const glatt = (a: number, b: number, v: number) => {
|
||||
const t = klemm01((v - a) / (b - a));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
// Ease-in-out zwischen Schlüsselwerten: [[frame, wert], ...]
|
||||
export const kurve = (frame: number, punkte: [number, number][]) => {
|
||||
if (frame <= punkte[0][0]) return punkte[0][1];
|
||||
for (let i = 1; i < punkte.length; i++) {
|
||||
const [f0, v0] = punkte[i - 1];
|
||||
const [f1, v1] = punkte[i];
|
||||
if (frame <= f1) {
|
||||
const t = (frame - f0) / (f1 - f0);
|
||||
return v0 + (v1 - v0) * (0.5 - 0.5 * Math.cos(Math.PI * t));
|
||||
}
|
||||
}
|
||||
return punkte[punkte.length - 1][1];
|
||||
};
|
||||
|
||||
// Gedämpfte Feder, die einem Zielsignal nachläuft (für Nachschwingen von
|
||||
// Bommel, Schal, Schwanz). Wird pro Frame deterministisch von 0 an simuliert.
|
||||
export const feder = (
|
||||
ziel: (f: number) => number,
|
||||
frame: number,
|
||||
{steifheit = 0.08, daempfung = 0.18} = {},
|
||||
) => {
|
||||
let x = ziel(0);
|
||||
let v = 0;
|
||||
for (let f = 1; f <= frame; f++) {
|
||||
v += (ziel(f) - x) * steifheit - v * daempfung;
|
||||
x += v;
|
||||
}
|
||||
return x;
|
||||
};
|
||||
|
||||
// Gewicht 1 innerhalb von r0 um (cx, cy), fällt bis r1 auf 0.
|
||||
export const kreis = (cx: number, cy: number, r0: number, r1: number) => (x: number, y: number) =>
|
||||
glatt(r1, r0, Math.hypot(x - cx, y - cy));
|
||||
17
remotion/src/szenen/Hintergruende.tsx
Normal file
17
remotion/src/szenen/Hintergruende.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import {AbsoluteFill} from 'remotion';
|
||||
import {Abendhuegel, Papier, Vordergrund} from '../teile/Abendhuegel';
|
||||
|
||||
// Standbilder zum Vorberechnen (npx remotion still …). Die SVG-Filter sind
|
||||
// teuer; als PNG kosten sie beim Rendern der Szenen fast nichts.
|
||||
|
||||
export const HintergrundAbend: React.FC = () => <Abendhuegel kameraX={0} />;
|
||||
|
||||
export const VordergrundAbend: React.FC = () => <Vordergrund kameraX={0} />;
|
||||
|
||||
// Nur die Papierstruktur, ohne Vignette (die ist billig und bleibt in der Szene).
|
||||
export const PapierStruktur: React.FC = () => (
|
||||
<AbsoluteFill>
|
||||
<Papier nurStruktur />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
139
remotion/src/szenen/VerformTest.tsx
Normal file
139
remotion/src/szenen/VerformTest.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import React from 'react';
|
||||
import {AbsoluteFill, Img, staticFile, useCurrentFrame} from 'remotion';
|
||||
import {feder, glatt, kreis, kurve} from '../bewegung';
|
||||
import {LULU, PINO} from '../figuren';
|
||||
import {VerformFigur, type Knochen} from '../teile/VerformFigur';
|
||||
|
||||
// Test für weiche Animation (6 s, ohne Ton): Lulu beugt den Hals zu Pino
|
||||
// hinunter, Pino freut sich und hüpft. Alles über Gitter-Verformung und
|
||||
// Federn, Hintergrund und Papier sind vorberechnete Bilder.
|
||||
|
||||
const BODEN = 884;
|
||||
|
||||
// ---------- Lulu ----------
|
||||
const halsKurve = (f: number) =>
|
||||
kurve(f, [
|
||||
[0, 0],
|
||||
[26, 0],
|
||||
[38, 5], // Anlauf: erst kurz zurück
|
||||
[74, -30],
|
||||
[118, -30],
|
||||
[150, -7],
|
||||
]);
|
||||
const neugierKurve = (f: number) => kurve(f, [[0, 0], [82, 0], [96, 11], [124, 11], [140, 0]]);
|
||||
const ohrZucken = (f: number, bei: number[]) => (bei.some((b) => f >= b && f < b + 4) ? 1 : 0);
|
||||
|
||||
const luluKnochen = (f: number): Knochen[] => {
|
||||
const hals = feder(halsKurve, f, {steifheit: 0.11, daempfung: 0.2});
|
||||
const halsTraege = feder(halsKurve, f, {steifheit: 0.05, daempfung: 0.16});
|
||||
const kopf = feder((g) => -halsKurve(g) * 0.35 + neugierKurve(g), f, {steifheit: 0.07, daempfung: 0.18});
|
||||
const ohrL = feder((g) => -26 * ohrZucken(g, [20, 98]), f, {steifheit: 0.25, daempfung: 0.12}) + (hals - halsTraege) * 0.6;
|
||||
const ohrR = feder((g) => 22 * ohrZucken(g, [60, 104]), f, {steifheit: 0.25, daempfung: 0.12}) - (hals - halsTraege) * 0.5;
|
||||
const schwanz = 13 * Math.sin(f * 0.085) + feder((g) => halsKurve(g) * 0.5, f, {steifheit: 0.04, daempfung: 0.1}) - halsKurve(f) * 0.5;
|
||||
const schal = (halsTraege - hals) * 0.9 + 2.5 * Math.sin(f * 0.07);
|
||||
const atmen = Math.sin(f * 0.1);
|
||||
return [
|
||||
{art: 'drehen', um: [45, 56], grad: ohrL, gewicht: kreis(22, 32, 18, 42)},
|
||||
{art: 'drehen', um: [150, 68], grad: ohrR, gewicht: kreis(176, 56, 16, 38)},
|
||||
{art: 'drehen', um: [112, 158], grad: kopf, gewicht: (x, y) => glatt(172, 128, y)},
|
||||
{art: 'drehen', um: [125, 238], grad: hals, gewicht: (x, y) => glatt(244, 150, y)},
|
||||
{
|
||||
art: 'drehen',
|
||||
um: [244, 288],
|
||||
grad: schwanz,
|
||||
gewicht: (x, y) => glatt(236, 252, x) * glatt(268, 284, y) * glatt(392, 376, y),
|
||||
},
|
||||
{
|
||||
art: 'drehen',
|
||||
um: [62, 252],
|
||||
grad: schal,
|
||||
gewicht: (x, y) => glatt(252, 330, y) * glatt(74, 62, x) * glatt(395, 380, y),
|
||||
},
|
||||
{art: 'stauchen', um: [175, 330], sx: 1 - atmen * 0.006, sy: 1 + atmen * 0.012, gewicht: kreis(180, 300, 50, 110)},
|
||||
];
|
||||
};
|
||||
|
||||
// ---------- Pino ----------
|
||||
// Sprung: Anlauf (Ducken), Flug, Landung mit Nachfedern.
|
||||
const SPRUNG = {duck: 84, ab: 93, auf: 111};
|
||||
const sprungHoehe = (f: number) => {
|
||||
if (f < SPRUNG.ab || f > SPRUNG.auf) return 0;
|
||||
const t = (f - SPRUNG.ab) / (SPRUNG.auf - SPRUNG.ab);
|
||||
return 4 * t * (1 - t);
|
||||
};
|
||||
const stauchZiel = (f: number) => {
|
||||
if (f >= SPRUNG.duck && f < SPRUNG.ab) return kurve(f, [[SPRUNG.duck, 1], [SPRUNG.ab - 2, 0.86]]);
|
||||
if (f >= SPRUNG.ab && f <= SPRUNG.auf) return 1 + 0.09 * Math.sin(Math.PI * Math.min(1, (f - SPRUNG.ab) / 8)) * (1 - sprungHoehe(f) * 0.3);
|
||||
if (f > SPRUNG.auf && f < SPRUNG.auf + 4) return 0.84;
|
||||
return 1;
|
||||
};
|
||||
const pinoKopfKurve = (f: number) => kurve(f, [[0, 0], [38, 0], [56, -9], [84, -9], [92, -3], [128, -3], [146, 7], [180, 7]]);
|
||||
const freude = (f: number) => (f > SPRUNG.auf + 4 && f < SPRUNG.auf + 40 ? Math.sin((f - SPRUNG.auf - 4) * 0.35) : 0);
|
||||
|
||||
const pinoWerte = (f: number) => {
|
||||
const stauch = feder(stauchZiel, f, {steifheit: 0.3, daempfung: 0.22});
|
||||
const hoch = sprungHoehe(f) * 80;
|
||||
const tempo = (g: number) => (sprungHoehe(g + 1) - sprungHoehe(g - 1)) * 40;
|
||||
const bommel = feder((g) => tempo(g) * 1.4, f, {steifheit: 0.12, daempfung: 0.1});
|
||||
const atmen = Math.sin(f * 0.11);
|
||||
const knochen: Knochen[] = [
|
||||
{art: 'drehen', um: [432, 92], grad: bommel + 3 * Math.sin(f * 0.06), gewicht: kreis(432, 48, 34, 56)},
|
||||
{
|
||||
art: 'drehen',
|
||||
um: [330, 292],
|
||||
grad: feder((g) => (g > SPRUNG.auf && g < SPRUNG.auf + 4 ? -20 : 0), f, {steifheit: 0.25, daempfung: 0.12}),
|
||||
gewicht: kreis(318, 266, 22, 42),
|
||||
},
|
||||
{art: 'drehen', um: [430, 500], grad: freude(f) * 14, gewicht: kreis(432, 522, 22, 42)},
|
||||
{art: 'drehen', um: [520, 495], grad: -freude(f) * 12, gewicht: kreis(540, 515, 20, 38)},
|
||||
{
|
||||
art: 'drehen',
|
||||
um: [380, 480],
|
||||
grad: feder(pinoKopfKurve, f, {steifheit: 0.08, daempfung: 0.2}),
|
||||
gewicht: (x, y) => glatt(520, 380, y) * glatt(230, 330, x),
|
||||
},
|
||||
{art: 'stauchen', um: [420, 600], sx: 1 + atmen * 0.01, sy: 1 + atmen * 0.014, gewicht: kreis(420, 540, 70, 170)},
|
||||
];
|
||||
return {stauch, hoch, knochen};
|
||||
};
|
||||
|
||||
export const VerformTest: React.FC = () => {
|
||||
const f = useCurrentFrame();
|
||||
const zoom = 1.04 - f * 0.0002;
|
||||
const pino = pinoWerte(f);
|
||||
const luluNeigen = kurve(f, [[30, 0], [74, -2.5], [118, -2.5], [150, 0]]) + Math.sin(f * 0.05) * 0.5;
|
||||
const abend = 'brightness(0.86) saturate(0.95)';
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: '#24325c', overflow: 'hidden'}}>
|
||||
<AbsoluteFill style={{transform: `scale(${zoom})`}}>
|
||||
<Img src={staticFile('hintergrund/abend.png')} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}} />
|
||||
<VerformFigur
|
||||
daten={PINO}
|
||||
hoehe={210}
|
||||
x={900}
|
||||
y={BODEN + 4 - pino.hoch}
|
||||
stauchX={2 - pino.stauch}
|
||||
stauchY={pino.stauch}
|
||||
knochen={pino.knochen}
|
||||
filter={abend}
|
||||
/>
|
||||
<VerformFigur
|
||||
daten={LULU}
|
||||
hoehe={680}
|
||||
x={1260}
|
||||
y={BODEN + 6}
|
||||
drehung={luluNeigen}
|
||||
knochen={luluKnochen(f)}
|
||||
filter="brightness(0.82) saturate(0.92)"
|
||||
/>
|
||||
<Img src={staticFile('hintergrund/vordergrund.png')} style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}} />
|
||||
</AbsoluteFill>
|
||||
<Img
|
||||
src={staticFile('hintergrund/papier.png')}
|
||||
style={{position: 'absolute', inset: 0, width: '100%', height: '100%', mixBlendMode: 'multiply', opacity: 0.35}}
|
||||
/>
|
||||
<AbsoluteFill style={{background: 'radial-gradient(ellipse at 50% 55%, rgba(0,0,0,0) 55%, rgba(10,12,30,0.45) 100%)'}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
|
@ -32,7 +32,7 @@ export const AquarellFilter: React.FC = () => (
|
|||
<feColorMatrix in="saum" type="matrix" values="0.7 0 0 0 0 0 0.7 0 0 0 0 0 0.75 0 0 0 0 0 0.5 0" result="saumDunkel" />
|
||||
{/* großflächige, weiche Farbschwankungen statt Körnung */}
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.0035 0.006" numOctaves="2" seed="9" result="flecken" />
|
||||
<feColorMatrix in="flecken" type="matrix" values="0 0 0 0 1 0 0 0 0 0.95 0 0 0 0 0.85 0 0 0 -0.55 0.3" result="hell" />
|
||||
<feColorMatrix in="flecken" type="matrix" values="0 0 0 0 1 0 0 0 0 0.95 0 0 0 0 0.85 0 0 0 -0.35 0.17" result="hell" />
|
||||
<feComposite in="hell" in2="rand" operator="in" result="wolken" />
|
||||
<feMerge>
|
||||
<feMergeNode in="rand" />
|
||||
|
|
@ -143,21 +143,24 @@ export const Vordergrund: React.FC<{kameraX: number}> = ({kameraX}) => {
|
|||
};
|
||||
|
||||
// Papierstruktur und Vignette über allem.
|
||||
export const Papier: React.FC = () => (
|
||||
export const Papier: React.FC<{nurStruktur?: boolean}> = ({nurStruktur = false}) => (
|
||||
<>
|
||||
<svg viewBox={`0 0 ${B} ${H}`} style={{position: 'absolute', inset: 0, width: '100%', height: '100%', mixBlendMode: 'multiply', opacity: 0.35}}>
|
||||
<svg
|
||||
viewBox={`0 0 ${B} ${H}`}
|
||||
style={{position: 'absolute', inset: 0, width: '100%', height: '100%', ...(nurStruktur ? {} : {mixBlendMode: 'multiply', opacity: 0.35})}}
|
||||
>
|
||||
<filter id="papier">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="3" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.55 0 0 0 0 0.5 0 0 0 0 0.45 0 0 0 0.6 0" />
|
||||
</filter>
|
||||
<rect width={B} height={H} filter="url(#papier)" />
|
||||
</svg>
|
||||
<div
|
||||
{!nurStruktur && <div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'radial-gradient(ellipse at 50% 55%, rgba(0,0,0,0) 55%, rgba(10,12,30,0.45) 100%)',
|
||||
}}
|
||||
/>
|
||||
/>}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
162
remotion/src/teile/VerformFigur.tsx
Normal file
162
remotion/src/teile/VerformFigur.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React, {useLayoutEffect, useRef, useState} from 'react';
|
||||
import {continueRender, delayRender, staticFile} from 'remotion';
|
||||
import * as THREE from 'three';
|
||||
import type {FigurDaten} from '../figuren';
|
||||
|
||||
// Eine Figur als verformbares Gitter (WebGL). Statt das Bild starr zu
|
||||
// verschieben, werden die Gitterpunkte über „Knochen“ gedreht und gestaucht.
|
||||
// So biegen sich Hals, Ohren, Schwanz usw. weich.
|
||||
|
||||
export type Gewicht = (x: number, y: number) => number;
|
||||
|
||||
export type Knochen =
|
||||
| {art: 'drehen'; um: [number, number]; grad: number; gewicht: Gewicht}
|
||||
| {art: 'stauchen'; um: [number, number]; sx: number; sy: number; gewicht: Gewicht};
|
||||
|
||||
const RAND = 0.35; // Platz um das Bild, falls Teile über den Rand hinausschwingen
|
||||
const SPALTEN = 48;
|
||||
const ZEILEN = 64;
|
||||
|
||||
// Knochen werden der Reihe nach angewendet: zuerst die äußeren (Ohr),
|
||||
// dann die inneren (Hals), damit Kinder mit den Eltern mitwandern.
|
||||
// Die Gewichte beziehen sich immer auf die Ruheposition.
|
||||
const verforme = (x: number, y: number, knochen: Knochen[]): [number, number] => {
|
||||
let px = x;
|
||||
let py = y;
|
||||
for (const k of knochen) {
|
||||
const w = k.gewicht(x, y);
|
||||
if (w <= 0) continue;
|
||||
const [ux, uy] = k.um;
|
||||
if (k.art === 'drehen') {
|
||||
const a = (k.grad * w * Math.PI) / 180;
|
||||
const dx = px - ux;
|
||||
const dy = py - uy;
|
||||
px = ux + dx * Math.cos(a) - dy * Math.sin(a);
|
||||
py = uy + dx * Math.sin(a) + dy * Math.cos(a);
|
||||
} else {
|
||||
px = ux + (px - ux) * (1 + (k.sx - 1) * w);
|
||||
py = uy + (py - uy) * (1 + (k.sy - 1) * w);
|
||||
}
|
||||
}
|
||||
return [px, py];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
daten: FigurDaten;
|
||||
hoehe: number;
|
||||
x: number;
|
||||
y: number;
|
||||
knochen: Knochen[];
|
||||
drehung?: number;
|
||||
stauchX?: number;
|
||||
stauchY?: number;
|
||||
filter?: string;
|
||||
};
|
||||
|
||||
type Szene = {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
szene: THREE.Scene;
|
||||
kamera: THREE.OrthographicCamera;
|
||||
pos: THREE.BufferAttribute;
|
||||
ruhe: Float32Array;
|
||||
};
|
||||
|
||||
export const VerformFigur: React.FC<Props> = ({daten, hoehe, x, y, knochen, drehung = 0, stauchX = 1, stauchY = 1, filter}) => {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const szene = useRef<Szene | null>(null);
|
||||
const [bereit, setBereit] = useState(false);
|
||||
const [warten] = useState(() => delayRender(`Figur ${daten.datei}`));
|
||||
|
||||
const W = daten.breite;
|
||||
const H = daten.hoehe;
|
||||
const mx = W * RAND;
|
||||
const my = H * RAND;
|
||||
const s = hoehe / H;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas.current!,
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
premultipliedAlpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
const kamera = new THREE.OrthographicCamera(-mx, W + mx, my, -(H + my), -10, 10);
|
||||
|
||||
const ruhe = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2);
|
||||
const uv = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2);
|
||||
const index: number[] = [];
|
||||
for (let j = 0; j <= ZEILEN; j++) {
|
||||
for (let i = 0; i <= SPALTEN; i++) {
|
||||
const n = j * (SPALTEN + 1) + i;
|
||||
ruhe[n * 2] = (i / SPALTEN) * W;
|
||||
ruhe[n * 2 + 1] = (j / ZEILEN) * H;
|
||||
uv[n * 2] = i / SPALTEN;
|
||||
uv[n * 2 + 1] = 1 - j / ZEILEN;
|
||||
if (i < SPALTEN && j < ZEILEN) {
|
||||
const a = n;
|
||||
const b = n + 1;
|
||||
const c = n + SPALTEN + 1;
|
||||
const d = c + 1;
|
||||
index.push(a, c, b, b, c, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const pos = new THREE.BufferAttribute(new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 3), 3);
|
||||
geo.setAttribute('position', pos);
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
geo.setIndex(index);
|
||||
|
||||
const welt = new THREE.Scene();
|
||||
new THREE.TextureLoader().load(staticFile(daten.datei), (tex) => {
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.premultiplyAlpha = true;
|
||||
tex.anisotropy = 4;
|
||||
const mat = new THREE.MeshBasicMaterial({map: tex, transparent: true, premultipliedAlpha: true});
|
||||
welt.add(new THREE.Mesh(geo, mat));
|
||||
szene.current = {renderer, szene: welt, kamera, pos, ruhe};
|
||||
setBereit(true);
|
||||
});
|
||||
return () => renderer.dispose();
|
||||
}, [daten.datei, W, H, mx, my]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sz = szene.current;
|
||||
if (!sz) return;
|
||||
const n = sz.ruhe.length / 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [px, py] = verforme(sz.ruhe[i * 2], sz.ruhe[i * 2 + 1], knochen);
|
||||
sz.pos.setXYZ(i, px, -py, 0);
|
||||
}
|
||||
sz.pos.needsUpdate = true;
|
||||
sz.renderer.render(sz.szene, sz.kamera);
|
||||
if (bereit) continueRender(warten);
|
||||
});
|
||||
|
||||
const breite = (W + 2 * mx) * s;
|
||||
const hoeheGesamt = (H + 2 * my) * s;
|
||||
// Füße (Bildmitte unten) liegen auf (x, y)
|
||||
const fussX = (mx + W / 2) * s;
|
||||
const fussY = (my + H) * s;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvas}
|
||||
width={Math.round(breite * 1.5)}
|
||||
height={Math.round(hoeheGesamt * 1.5)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x - fussX,
|
||||
top: y - fussY,
|
||||
width: breite,
|
||||
height: hoeheGesamt,
|
||||
transformOrigin: `${fussX}px ${fussY}px`,
|
||||
transform: `rotate(${drehung}deg) scale(${stauchX}, ${stauchY})`,
|
||||
filter,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue