Optimize admin performance and split render surface
This commit is contained in:
+14
-2420
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
||||
import * as THREE from "three";
|
||||
import { flattenSceneParams, mergeSceneParams, type CueTransition } from "@goodgrief/shared-types";
|
||||
import { loadScenePlugin, loadTextOverlayModule, preloadScenePlugin, preloadTextOverlayModule } from "./scene-loader";
|
||||
import type {
|
||||
LoadedPhotoAsset,
|
||||
SceneInstance,
|
||||
SceneParams,
|
||||
ScenePlugin,
|
||||
SceneViewport,
|
||||
SurfacePresentation,
|
||||
SurfaceQualityProfile
|
||||
} from "./types";
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
class TextureCache {
|
||||
private readonly loader = new THREE.TextureLoader();
|
||||
private readonly cache = new Map<string, Promise<THREE.Texture | null>>();
|
||||
|
||||
async load(url: string | null) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = this.cache.get(url);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const promise = this.loader
|
||||
.loadAsync(url)
|
||||
.then((texture) => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = false;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
})
|
||||
.catch(() => null)
|
||||
.then((texture) => {
|
||||
if (!texture) {
|
||||
this.cache.delete(url);
|
||||
}
|
||||
return texture;
|
||||
});
|
||||
|
||||
this.cache.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
const textures = await Promise.all(this.cache.values());
|
||||
textures.forEach((texture) => texture?.dispose());
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const disposeObject3D = (root: THREE.Object3D) => {
|
||||
root.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
if ("geometry" in mesh && mesh.geometry) {
|
||||
mesh.geometry.dispose();
|
||||
}
|
||||
const material = (mesh as { material?: THREE.Material | THREE.Material[] }).material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((entry) => entry.dispose());
|
||||
} else {
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
interface SceneRuntime {
|
||||
presentation: SurfacePresentation;
|
||||
params: SceneParams;
|
||||
targetMotion: number;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
instance: SceneInstance;
|
||||
}
|
||||
|
||||
interface TransitionRuntime {
|
||||
from: SceneRuntime;
|
||||
to: SceneRuntime;
|
||||
style: CueTransition["style"];
|
||||
durationMs: number;
|
||||
startedAtMs: number;
|
||||
}
|
||||
|
||||
interface BlackoutRuntime {
|
||||
fromLevel: number;
|
||||
toLevel: number;
|
||||
style: CueTransition["style"];
|
||||
durationMs: number;
|
||||
startedAtMs: number;
|
||||
}
|
||||
|
||||
const createSceneCamera = (viewport: SceneViewport) => {
|
||||
const camera = new THREE.PerspectiveCamera(32, viewport.aspect, 0.1, 100);
|
||||
camera.position.set(0, 0, 7.2);
|
||||
camera.lookAt(0, 0, -3.2);
|
||||
return camera;
|
||||
};
|
||||
|
||||
const updateRuntimeCamera = (runtime: SceneRuntime, viewport: SceneViewport) => {
|
||||
runtime.camera.aspect = viewport.aspect;
|
||||
runtime.camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
const updateSceneRuntime = (
|
||||
runtime: SceneRuntime,
|
||||
context: { elapsedMs: number; deltaMs: number; viewport: SceneViewport }
|
||||
) => {
|
||||
const motionLerp = 1 - Math.exp(-context.deltaMs / 140);
|
||||
runtime.params.composition.motion = THREE.MathUtils.lerp(
|
||||
runtime.params.composition.motion,
|
||||
runtime.targetMotion,
|
||||
clamp(motionLerp, 0, 1)
|
||||
);
|
||||
runtime.instance.update?.(context);
|
||||
};
|
||||
|
||||
const resolvePresentationParams = (presentation: SurfacePresentation) =>
|
||||
mergeSceneParams(presentation.definition.defaultParams, presentation.cue?.parameterOverrides, presentation.params);
|
||||
|
||||
const liveMutableParamPaths = new Set([
|
||||
"composition.motion",
|
||||
"composition.cameraTravel",
|
||||
"composition.orbitAmount",
|
||||
"scenicTreatment.fieldType",
|
||||
"scenicTreatment.fieldIntensity",
|
||||
"scenicTreatment.fieldScale",
|
||||
"scenicTreatment.fieldSpeed",
|
||||
"scenicTreatment.hue",
|
||||
"scenicTreatment.saturation",
|
||||
"scenicTreatment.lightness"
|
||||
]);
|
||||
|
||||
const createPresentationStructureSignature = (presentation: SurfacePresentation) => {
|
||||
const params = flattenSceneParams(resolvePresentationParams(presentation));
|
||||
for (const path of liveMutableParamPaths) {
|
||||
delete params[path];
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
definitionId: presentation.definition.id,
|
||||
effectPresetId: presentation.effectPresetId ?? null,
|
||||
modeKey: presentation.modeKey ?? null,
|
||||
assetIds: presentation.assets.map((asset) => asset.id),
|
||||
textFragments: presentation.textFragments ?? [],
|
||||
anchorCaption: presentation.anchorCaption ?? null,
|
||||
params
|
||||
});
|
||||
};
|
||||
|
||||
const canPatchRuntimeInPlace = (runtime: SceneRuntime, presentation: SurfacePresentation) =>
|
||||
createPresentationStructureSignature(runtime.presentation) === createPresentationStructureSignature(presentation);
|
||||
|
||||
const applyPresentationToRuntime = (runtime: SceneRuntime, presentation: SurfacePresentation) => {
|
||||
const mergedParams = resolvePresentationParams(presentation);
|
||||
const currentMotion = runtime.params.composition.motion;
|
||||
Object.assign(runtime.params.photoTreatment, mergedParams.photoTreatment);
|
||||
Object.assign(runtime.params.scenicTreatment, mergedParams.scenicTreatment);
|
||||
Object.assign(runtime.params.composition, mergedParams.composition);
|
||||
Object.assign(runtime.params.textTreatment, mergedParams.textTreatment);
|
||||
runtime.params.composition.motion = currentMotion;
|
||||
runtime.presentation = presentation;
|
||||
runtime.targetMotion = mergedParams.composition.motion;
|
||||
};
|
||||
|
||||
const disposeSceneRuntime = (runtime: SceneRuntime | null) => {
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
runtime.scene.remove(runtime.instance.root);
|
||||
disposeObject3D(runtime.instance.root);
|
||||
runtime.instance.dispose?.();
|
||||
runtime.scene.clear();
|
||||
};
|
||||
|
||||
const combineInstances = (...instances: Array<SceneInstance | null | undefined>): SceneInstance => {
|
||||
const active = instances.filter((instance): instance is SceneInstance => Boolean(instance));
|
||||
if (active.length === 1) {
|
||||
return active[0]!;
|
||||
}
|
||||
|
||||
const root = new THREE.Group();
|
||||
active.forEach((instance) => root.add(instance.root));
|
||||
return {
|
||||
root,
|
||||
update: (context) => active.forEach((instance) => instance.update?.(context)),
|
||||
dispose: () => active.forEach((instance) => instance.dispose?.())
|
||||
};
|
||||
};
|
||||
|
||||
const shouldLoadTextOverlay = (presentation: SurfacePresentation, params: SceneParams) =>
|
||||
params.textTreatment.mode !== "off" &&
|
||||
(presentation.textFragments ?? []).some((value) => value.trim().length > 0);
|
||||
|
||||
export class RenderSurface {
|
||||
private readonly renderer: THREE.WebGLRenderer;
|
||||
private readonly registry = new Map<string, ScenePlugin>();
|
||||
private readonly textureCache = new TextureCache();
|
||||
private readonly compositeScene = new THREE.Scene();
|
||||
private readonly compositeCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
|
||||
private readonly compositeFromMaterial = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide,
|
||||
toneMapped: false
|
||||
});
|
||||
private readonly compositeToMaterial = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide,
|
||||
toneMapped: false
|
||||
});
|
||||
private readonly compositeFromQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.compositeFromMaterial);
|
||||
private readonly compositeToQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.compositeToMaterial);
|
||||
private readonly blackoutQuad = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2, 2),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: "#000000",
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide,
|
||||
toneMapped: false
|
||||
})
|
||||
);
|
||||
private readonly shutterBars = Array.from({ length: 8 }, () =>
|
||||
new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2.4, 0.18),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: "#0a0d14",
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide,
|
||||
toneMapped: false
|
||||
})
|
||||
)
|
||||
);
|
||||
private readonly veilOverlay = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2, 2),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: "#121a26",
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.NormalBlending,
|
||||
toneMapped: false
|
||||
})
|
||||
);
|
||||
private readonly compositeResolution = new THREE.Vector2(1280, 720);
|
||||
private readonly fromTarget = new THREE.WebGLRenderTarget(1280, 720, {
|
||||
depthBuffer: true,
|
||||
stencilBuffer: false
|
||||
});
|
||||
private readonly toTarget = new THREE.WebGLRenderTarget(1280, 720, {
|
||||
depthBuffer: true,
|
||||
stencilBuffer: false
|
||||
});
|
||||
private viewport: SceneViewport = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
aspect: 16 / 9
|
||||
};
|
||||
private currentRuntime: SceneRuntime | null = null;
|
||||
private transitionRuntime: TransitionRuntime | null = null;
|
||||
private blackoutRuntime: BlackoutRuntime | null = null;
|
||||
private lastFrameMs = 0;
|
||||
private blackoutLevel = 0;
|
||||
private activationToken = 0;
|
||||
private activePresentationKey: string | undefined;
|
||||
private qualityProfile: SurfaceQualityProfile = "program-monitor";
|
||||
private busy = false;
|
||||
private paused = false;
|
||||
private readonly animationLoop = (timestamp: number) => {
|
||||
const minFrameIntervalMs = this.getMinFrameIntervalMs();
|
||||
if (minFrameIntervalMs > 0 && this.lastFrameMs !== 0 && timestamp - this.lastFrameMs < minFrameIntervalMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaMs = this.lastFrameMs === 0 ? 16.6 : timestamp - this.lastFrameMs;
|
||||
this.lastFrameMs = timestamp;
|
||||
|
||||
const context = {
|
||||
elapsedMs: timestamp,
|
||||
deltaMs,
|
||||
viewport: this.viewport
|
||||
};
|
||||
|
||||
this.renderer.setClearColor("#040508", 1);
|
||||
|
||||
if (this.transitionRuntime) {
|
||||
const progress = clamp((timestamp - this.transitionRuntime.startedAtMs) / this.transitionRuntime.durationMs, 0, 1);
|
||||
updateSceneRuntime(this.transitionRuntime.from, context);
|
||||
updateSceneRuntime(this.transitionRuntime.to, context);
|
||||
this.renderRuntimeToTarget(this.transitionRuntime.from, this.fromTarget);
|
||||
this.renderRuntimeToTarget(this.transitionRuntime.to, this.toTarget);
|
||||
this.renderCompositeTransition(progress, this.transitionRuntime.style);
|
||||
this.renderBlackoutOverlay(timestamp);
|
||||
if (progress >= 1) {
|
||||
this.finishTransition();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.currentRuntime) {
|
||||
this.renderer.setRenderTarget(null);
|
||||
this.renderer.clear();
|
||||
this.renderBlackoutOverlay(timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSceneRuntime(this.currentRuntime, context);
|
||||
this.renderRuntimeToTarget(this.currentRuntime, this.toTarget);
|
||||
this.renderTargetToScreen(this.toTarget);
|
||||
this.renderBlackoutOverlay(timestamp);
|
||||
};
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
alpha: true
|
||||
});
|
||||
this.renderer.toneMapping = THREE.NoToneMapping;
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
this.renderer.setClearColor("#040508", 1);
|
||||
this.fromTarget.texture.colorSpace = THREE.NoColorSpace;
|
||||
this.toTarget.texture.colorSpace = THREE.NoColorSpace;
|
||||
this.fromTarget.texture.minFilter = THREE.LinearFilter;
|
||||
this.fromTarget.texture.magFilter = THREE.LinearFilter;
|
||||
this.toTarget.texture.minFilter = THREE.LinearFilter;
|
||||
this.toTarget.texture.magFilter = THREE.LinearFilter;
|
||||
|
||||
this.compositeCamera.position.set(0, 0, 1);
|
||||
this.compositeCamera.lookAt(0, 0, 0);
|
||||
|
||||
this.compositeFromQuad.position.z = 0;
|
||||
this.compositeToQuad.position.z = 0.01;
|
||||
this.blackoutQuad.position.z = 0.019;
|
||||
this.veilOverlay.position.z = 0.02;
|
||||
this.compositeScene.add(this.compositeFromQuad, this.compositeToQuad, this.blackoutQuad, this.veilOverlay);
|
||||
this.shutterBars.forEach((bar, index) => {
|
||||
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
|
||||
this.compositeScene.add(bar);
|
||||
});
|
||||
|
||||
this.applyQualitySettings();
|
||||
this.startAnimationLoop();
|
||||
preloadScenePlugin("safe-hold");
|
||||
}
|
||||
|
||||
register(plugin: ScenePlugin) {
|
||||
this.registry.set(plugin.sceneKey, plugin);
|
||||
}
|
||||
|
||||
registerMany(plugins: ScenePlugin[]) {
|
||||
plugins.forEach((plugin) => this.register(plugin));
|
||||
}
|
||||
|
||||
preloadPresentation(presentation: SurfacePresentation | null) {
|
||||
if (!presentation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = resolvePresentationParams(presentation);
|
||||
if (!this.registry.has(presentation.definition.sceneKey)) {
|
||||
preloadScenePlugin(presentation.definition.sceneKey);
|
||||
}
|
||||
if (shouldLoadTextOverlay(presentation, params)) {
|
||||
preloadTextOverlayModule();
|
||||
}
|
||||
}
|
||||
|
||||
setQualityProfile(profile: SurfaceQualityProfile) {
|
||||
if (this.qualityProfile === profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.qualityProfile = profile;
|
||||
this.applyQualitySettings();
|
||||
}
|
||||
|
||||
setBusy(busy: boolean) {
|
||||
if (this.busy === busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.busy = busy;
|
||||
this.applyQualitySettings();
|
||||
}
|
||||
|
||||
setPaused(paused: boolean) {
|
||||
if (this.paused === paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.paused = paused;
|
||||
if (paused) {
|
||||
this.renderer.setAnimationLoop(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastFrameMs = 0;
|
||||
this.startAnimationLoop();
|
||||
}
|
||||
|
||||
setSize(width: number, height: number) {
|
||||
this.viewport = {
|
||||
width,
|
||||
height,
|
||||
aspect: width / Math.max(height, 1)
|
||||
};
|
||||
this.renderer.setSize(width, height, false);
|
||||
this.compositeResolution.set(
|
||||
Math.max(1, Math.round(width * this.renderer.getPixelRatio())),
|
||||
Math.max(1, Math.round(height * this.renderer.getPixelRatio()))
|
||||
);
|
||||
this.fromTarget.setSize(
|
||||
Math.max(1, Math.round(width * this.renderer.getPixelRatio())),
|
||||
Math.max(1, Math.round(height * this.renderer.getPixelRatio()))
|
||||
);
|
||||
this.toTarget.setSize(
|
||||
Math.max(1, Math.round(width * this.renderer.getPixelRatio())),
|
||||
Math.max(1, Math.round(height * this.renderer.getPixelRatio()))
|
||||
);
|
||||
if (this.currentRuntime) {
|
||||
updateRuntimeCamera(this.currentRuntime, this.viewport);
|
||||
}
|
||||
if (this.transitionRuntime) {
|
||||
updateRuntimeCamera(this.transitionRuntime.from, this.viewport);
|
||||
updateRuntimeCamera(this.transitionRuntime.to, this.viewport);
|
||||
}
|
||||
}
|
||||
|
||||
setBlackout(blackout: boolean, transition?: CueTransition | null, immediate = false) {
|
||||
const nextLevel = blackout ? 1 : 0;
|
||||
const now = this.lastFrameMs || (typeof performance !== "undefined" ? performance.now() : 0);
|
||||
|
||||
if (this.blackoutRuntime) {
|
||||
const progress = clamp((now - this.blackoutRuntime.startedAtMs) / this.blackoutRuntime.durationMs, 0, 1);
|
||||
const eased = THREE.MathUtils.smoothstep(progress, 0, 1);
|
||||
this.blackoutLevel = THREE.MathUtils.lerp(this.blackoutRuntime.fromLevel, this.blackoutRuntime.toLevel, eased);
|
||||
this.blackoutRuntime = null;
|
||||
}
|
||||
|
||||
if (Math.abs(nextLevel - this.blackoutLevel) < 0.001) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (immediate || !transition || transition.style === "cut" || transition.durationMs <= 0) {
|
||||
this.blackoutLevel = nextLevel;
|
||||
return;
|
||||
}
|
||||
|
||||
this.blackoutRuntime = {
|
||||
fromLevel: this.blackoutLevel,
|
||||
toLevel: nextLevel,
|
||||
style: transition.style,
|
||||
durationMs: transition.durationMs,
|
||||
startedAtMs: now
|
||||
};
|
||||
}
|
||||
|
||||
updatePresentation(presentation: SurfacePresentation | null, activationKey?: string) {
|
||||
if (!presentation || activationKey !== this.activePresentationKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.transitionRuntime?.to && canPatchRuntimeInPlace(this.transitionRuntime.to, presentation)) {
|
||||
applyPresentationToRuntime(this.transitionRuntime.to, presentation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentRuntime && canPatchRuntimeInPlace(this.currentRuntime, presentation)) {
|
||||
applyPresentationToRuntime(this.currentRuntime, presentation);
|
||||
}
|
||||
}
|
||||
|
||||
async activate(presentation: SurfacePresentation | null, transition?: CueTransition | null, activationKey?: string) {
|
||||
const token = this.activationToken + 1;
|
||||
this.activationToken = token;
|
||||
this.activePresentationKey = activationKey;
|
||||
|
||||
if (!presentation) {
|
||||
this.clearAll();
|
||||
return;
|
||||
}
|
||||
|
||||
this.preloadPresentation(presentation);
|
||||
|
||||
if (
|
||||
this.currentRuntime &&
|
||||
(!transition || transition.style === "cut" || transition.durationMs <= 0) &&
|
||||
canPatchRuntimeInPlace(this.currentRuntime, presentation)
|
||||
) {
|
||||
applyPresentationToRuntime(this.currentRuntime, presentation);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRuntime = await this.buildRuntime(presentation);
|
||||
if (token !== this.activationToken) {
|
||||
disposeSceneRuntime(nextRuntime);
|
||||
return;
|
||||
}
|
||||
|
||||
this.collapseTransitionToCurrent();
|
||||
|
||||
if (!this.currentRuntime || !transition || transition.style === "cut" || transition.durationMs <= 0) {
|
||||
disposeSceneRuntime(this.currentRuntime);
|
||||
this.currentRuntime = nextRuntime;
|
||||
return;
|
||||
}
|
||||
|
||||
const fromRuntime = this.currentRuntime;
|
||||
this.currentRuntime = null;
|
||||
this.transitionRuntime = {
|
||||
from: fromRuntime,
|
||||
to: nextRuntime,
|
||||
style: transition.style,
|
||||
durationMs: transition.durationMs,
|
||||
startedAtMs: this.lastFrameMs || (typeof performance !== "undefined" ? performance.now() : 0)
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.clearAll();
|
||||
this.renderer.setAnimationLoop(null);
|
||||
this.fromTarget.dispose();
|
||||
this.toTarget.dispose();
|
||||
this.compositeFromMaterial.dispose();
|
||||
this.compositeToMaterial.dispose();
|
||||
(this.blackoutQuad.material as THREE.Material).dispose();
|
||||
(this.veilOverlay.material as THREE.Material).dispose();
|
||||
this.shutterBars.forEach((bar) => (bar.material as THREE.Material).dispose());
|
||||
this.renderer.dispose();
|
||||
void this.textureCache.clear();
|
||||
}
|
||||
|
||||
private async buildRuntime(presentation: SurfacePresentation) {
|
||||
const mergedParams = resolvePresentationParams(presentation);
|
||||
const pluginPromise = this.registry.get(presentation.definition.sceneKey)
|
||||
? Promise.resolve(this.registry.get(presentation.definition.sceneKey)!)
|
||||
: loadScenePlugin(presentation.definition.sceneKey).catch(() => loadScenePlugin("witness-float"));
|
||||
const textOverlayPromise = shouldLoadTextOverlay(presentation, mergedParams) ? loadTextOverlayModule() : Promise.resolve(null);
|
||||
|
||||
const loadedAssets = await Promise.all(
|
||||
presentation.assets.map(async (asset) => {
|
||||
const sourceCandidates = Array.from(
|
||||
new Set([asset.renderKey, asset.previewKey, asset.thumbKey, asset.originalKey].filter(Boolean))
|
||||
) as string[];
|
||||
let texture: THREE.Texture | null = null;
|
||||
let sourceUrl: string | null = null;
|
||||
for (const candidate of sourceCandidates) {
|
||||
texture = await this.textureCache.load(candidate);
|
||||
if (texture) {
|
||||
sourceUrl = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
asset,
|
||||
texture,
|
||||
sourceUrl,
|
||||
aspect: asset.width && asset.height ? asset.width / asset.height : 4 / 3,
|
||||
dominantColor: asset.dominantColor ?? "#93a6ba"
|
||||
} satisfies LoadedPhotoAsset;
|
||||
})
|
||||
);
|
||||
|
||||
const [plugin, textOverlayModule] = await Promise.all([pluginPromise, textOverlayPromise]);
|
||||
const scene = new THREE.Scene();
|
||||
const camera = createSceneCamera(this.viewport);
|
||||
const activationInput = {
|
||||
...presentation,
|
||||
loadedAssets,
|
||||
params: mergedParams,
|
||||
camera,
|
||||
viewport: this.viewport
|
||||
};
|
||||
const baseInstance = plugin.build(activationInput);
|
||||
const textOverlay = textOverlayModule?.buildTextOverlay(activationInput) ?? null;
|
||||
const instance = combineInstances(baseInstance, textOverlay);
|
||||
scene.add(instance.root);
|
||||
|
||||
return {
|
||||
presentation,
|
||||
params: mergedParams,
|
||||
targetMotion: mergedParams.composition.motion,
|
||||
scene,
|
||||
camera,
|
||||
instance
|
||||
} satisfies SceneRuntime;
|
||||
}
|
||||
|
||||
private renderRuntimeToTarget(runtime: SceneRuntime, target: THREE.WebGLRenderTarget) {
|
||||
this.renderer.setRenderTarget(target);
|
||||
this.renderer.clear();
|
||||
this.renderer.render(runtime.scene, runtime.camera);
|
||||
}
|
||||
|
||||
private renderCompositeTransition(progress: number, style: CueTransition["style"]) {
|
||||
const eased = THREE.MathUtils.smoothstep(progress, 0, 1);
|
||||
const reveal = style === "shutter_reveal" ? THREE.MathUtils.smoothstep(progress, 0.08, 1) : eased;
|
||||
|
||||
this.compositeFromMaterial.map = this.fromTarget.texture;
|
||||
this.compositeToMaterial.map = this.toTarget.texture;
|
||||
this.compositeFromMaterial.needsUpdate = true;
|
||||
this.compositeToMaterial.needsUpdate = true;
|
||||
|
||||
this.compositeFromMaterial.opacity = 1;
|
||||
this.compositeToMaterial.opacity = reveal;
|
||||
this.compositeFromQuad.position.set(0, 0, 0);
|
||||
this.compositeToQuad.position.set(0, 0, 0.01);
|
||||
this.compositeFromQuad.scale.set(1, 1, 1);
|
||||
this.compositeToQuad.scale.set(1, 1, 1);
|
||||
this.compositeFromQuad.rotation.z = 0;
|
||||
this.compositeToQuad.rotation.z = 0;
|
||||
(this.blackoutQuad.material as THREE.MeshBasicMaterial).opacity = 0;
|
||||
|
||||
const veilMaterial = this.veilOverlay.material as THREE.MeshBasicMaterial;
|
||||
veilMaterial.opacity = 0;
|
||||
|
||||
this.shutterBars.forEach((bar, index) => {
|
||||
const material = bar.material as THREE.MeshBasicMaterial;
|
||||
material.opacity = 0;
|
||||
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
|
||||
});
|
||||
|
||||
if (style === "mist_reveal") {
|
||||
this.compositeFromQuad.scale.setScalar(1 + (1 - eased) * 0.015);
|
||||
this.compositeToQuad.scale.setScalar(0.985 + eased * 0.015);
|
||||
veilMaterial.opacity = Math.sin(eased * Math.PI) * 0.04;
|
||||
} else if (style === "depth_drift") {
|
||||
this.compositeFromQuad.position.x = -0.04 * eased;
|
||||
this.compositeToQuad.position.x = 0.04 * (1 - eased);
|
||||
this.compositeFromQuad.scale.setScalar(1 + eased * 0.025);
|
||||
this.compositeToQuad.scale.setScalar(0.975 + eased * 0.025);
|
||||
} else if (style === "shutter_reveal") {
|
||||
this.shutterBars.forEach((bar, index) => {
|
||||
const material = bar.material as THREE.MeshBasicMaterial;
|
||||
const bandProgress = THREE.MathUtils.clamp((progress - index * 0.05) / 0.45, 0, 1);
|
||||
material.opacity = (1 - bandProgress) * 0.22;
|
||||
bar.position.x = -1.2 + bandProgress * 2.4;
|
||||
});
|
||||
}
|
||||
|
||||
this.renderer.setRenderTarget(null);
|
||||
this.renderer.clear();
|
||||
this.renderer.render(this.compositeScene, this.compositeCamera);
|
||||
}
|
||||
|
||||
private renderTargetToScreen(target: THREE.WebGLRenderTarget) {
|
||||
this.compositeFromMaterial.map = target.texture;
|
||||
this.compositeToMaterial.map = target.texture;
|
||||
this.compositeFromMaterial.needsUpdate = true;
|
||||
this.compositeToMaterial.needsUpdate = true;
|
||||
this.compositeFromMaterial.opacity = 1;
|
||||
this.compositeToMaterial.opacity = 0;
|
||||
this.compositeFromQuad.position.set(0, 0, 0);
|
||||
this.compositeToQuad.position.set(0, 0, 0.01);
|
||||
this.compositeFromQuad.scale.set(1, 1, 1);
|
||||
this.compositeToQuad.scale.set(1, 1, 1);
|
||||
this.compositeFromQuad.rotation.z = 0;
|
||||
this.compositeToQuad.rotation.z = 0;
|
||||
(this.blackoutQuad.material as THREE.MeshBasicMaterial).opacity = 0;
|
||||
(this.veilOverlay.material as THREE.MeshBasicMaterial).opacity = 0;
|
||||
this.shutterBars.forEach((bar, index) => {
|
||||
const material = bar.material as THREE.MeshBasicMaterial;
|
||||
material.opacity = 0;
|
||||
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
|
||||
});
|
||||
this.renderer.setRenderTarget(null);
|
||||
this.renderer.clear();
|
||||
this.renderer.render(this.compositeScene, this.compositeCamera);
|
||||
}
|
||||
|
||||
private renderBlackoutOverlay(timestamp: number) {
|
||||
let style: CueTransition["style"] = "dissolve";
|
||||
let progress = this.blackoutLevel > 0 ? 1 : 0;
|
||||
let toBlack = this.blackoutLevel >= 1;
|
||||
|
||||
if (this.blackoutRuntime) {
|
||||
const rawProgress = clamp((timestamp - this.blackoutRuntime.startedAtMs) / this.blackoutRuntime.durationMs, 0, 1);
|
||||
const eased = THREE.MathUtils.smoothstep(rawProgress, 0, 1);
|
||||
this.blackoutLevel = THREE.MathUtils.lerp(this.blackoutRuntime.fromLevel, this.blackoutRuntime.toLevel, eased);
|
||||
style = this.blackoutRuntime.style;
|
||||
progress = eased;
|
||||
toBlack = this.blackoutRuntime.toLevel > this.blackoutRuntime.fromLevel;
|
||||
if (rawProgress >= 1) {
|
||||
this.blackoutLevel = this.blackoutRuntime.toLevel;
|
||||
this.blackoutRuntime = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.blackoutLevel <= 0.001) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.compositeFromMaterial.opacity = 0;
|
||||
this.compositeToMaterial.opacity = 0;
|
||||
this.compositeFromQuad.position.set(0, 0, 0);
|
||||
this.compositeToQuad.position.set(0, 0, 0.01);
|
||||
this.compositeFromQuad.scale.set(1, 1, 1);
|
||||
this.compositeToQuad.scale.set(1, 1, 1);
|
||||
this.compositeFromQuad.rotation.z = 0;
|
||||
this.compositeToQuad.rotation.z = 0;
|
||||
|
||||
const blackoutMaterial = this.blackoutQuad.material as THREE.MeshBasicMaterial;
|
||||
blackoutMaterial.opacity = clamp(this.blackoutLevel, 0, 1);
|
||||
this.blackoutQuad.position.set(0, 0, 0.019);
|
||||
this.blackoutQuad.scale.set(1, 1, 1);
|
||||
this.blackoutQuad.rotation.z = 0;
|
||||
|
||||
const veilMaterial = this.veilOverlay.material as THREE.MeshBasicMaterial;
|
||||
veilMaterial.opacity = 0;
|
||||
|
||||
this.shutterBars.forEach((bar, index) => {
|
||||
const material = bar.material as THREE.MeshBasicMaterial;
|
||||
material.opacity = 0;
|
||||
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
|
||||
});
|
||||
|
||||
if (style === "mist_reveal" && this.blackoutRuntime) {
|
||||
veilMaterial.opacity = (0.02 + this.blackoutLevel * 0.08) * Math.sin(progress * Math.PI);
|
||||
blackoutMaterial.opacity = clamp(this.blackoutLevel * 1.02, 0, 1);
|
||||
} else if (style === "depth_drift" && this.blackoutRuntime) {
|
||||
const offset = (toBlack ? 1 - progress : progress - 1) * 0.06;
|
||||
this.blackoutQuad.position.x = offset;
|
||||
this.blackoutQuad.scale.set(1.03, 1, 1);
|
||||
veilMaterial.opacity = Math.sin(progress * Math.PI) * 0.05;
|
||||
} else if (style === "shutter_reveal" && this.blackoutRuntime) {
|
||||
blackoutMaterial.opacity = clamp(Math.max(this.blackoutLevel, toBlack ? progress * 0.82 : this.blackoutLevel), 0, 1);
|
||||
this.shutterBars.forEach((bar, index) => {
|
||||
const material = bar.material as THREE.MeshBasicMaterial;
|
||||
const bandProgress = THREE.MathUtils.clamp((progress - index * 0.05) / 0.45, 0, 1);
|
||||
material.opacity = (1 - bandProgress) * 0.3;
|
||||
bar.position.x = toBlack ? -1.2 + bandProgress * 2.4 : 1.2 - bandProgress * 2.4;
|
||||
});
|
||||
}
|
||||
|
||||
const previousAutoClear = this.renderer.autoClear;
|
||||
this.renderer.autoClear = false;
|
||||
this.renderer.setRenderTarget(null);
|
||||
this.renderer.render(this.compositeScene, this.compositeCamera);
|
||||
this.renderer.autoClear = previousAutoClear;
|
||||
}
|
||||
|
||||
private finishTransition() {
|
||||
if (!this.transitionRuntime) {
|
||||
return;
|
||||
}
|
||||
disposeSceneRuntime(this.transitionRuntime.from);
|
||||
this.currentRuntime = this.transitionRuntime.to;
|
||||
this.transitionRuntime = null;
|
||||
}
|
||||
|
||||
private collapseTransitionToCurrent() {
|
||||
if (!this.transitionRuntime) {
|
||||
return;
|
||||
}
|
||||
disposeSceneRuntime(this.transitionRuntime.from);
|
||||
this.currentRuntime = this.transitionRuntime.to;
|
||||
this.transitionRuntime = null;
|
||||
}
|
||||
|
||||
private clearAll() {
|
||||
disposeSceneRuntime(this.currentRuntime);
|
||||
this.currentRuntime = null;
|
||||
if (this.transitionRuntime) {
|
||||
disposeSceneRuntime(this.transitionRuntime.from);
|
||||
disposeSceneRuntime(this.transitionRuntime.to);
|
||||
this.transitionRuntime = null;
|
||||
}
|
||||
this.blackoutRuntime = null;
|
||||
this.activePresentationKey = undefined;
|
||||
}
|
||||
|
||||
private getMinFrameIntervalMs() {
|
||||
if (this.qualityProfile === "program-output") {
|
||||
return this.busy ? 1000 / 30 : 1000 / 45;
|
||||
}
|
||||
|
||||
if (this.qualityProfile === "program-monitor") {
|
||||
return this.busy ? 1000 / 20 : 1000 / 30;
|
||||
}
|
||||
|
||||
return this.busy ? 1000 / 18 : 1000 / 24;
|
||||
}
|
||||
|
||||
private applyQualitySettings() {
|
||||
const devicePixelRatio = typeof window === "undefined" ? 1 : Math.max(1, window.devicePixelRatio || 1);
|
||||
const scale =
|
||||
this.qualityProfile === "program-output"
|
||||
? this.busy
|
||||
? 0.82
|
||||
: 0.96
|
||||
: this.qualityProfile === "program-monitor"
|
||||
? this.busy
|
||||
? 0.68
|
||||
: 0.82
|
||||
: this.busy
|
||||
? 0.46
|
||||
: 0.58;
|
||||
const cap =
|
||||
this.qualityProfile === "program-output"
|
||||
? 1.35
|
||||
: this.qualityProfile === "program-monitor"
|
||||
? 1
|
||||
: 0.85;
|
||||
const pixelRatio = Math.min(devicePixelRatio * scale, cap);
|
||||
this.renderer.setPixelRatio(pixelRatio);
|
||||
this.lastFrameMs = 0;
|
||||
this.setSize(this.viewport.width, this.viewport.height);
|
||||
}
|
||||
|
||||
private startAnimationLoop() {
|
||||
if (!this.paused) {
|
||||
this.renderer.setAnimationLoop(this.animationLoop);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
import * as THREE from "three";
|
||||
import type { ScenicFieldType } from "@goodgrief/shared-types";
|
||||
import type {
|
||||
LoadedPhotoAsset,
|
||||
SceneActivationInput,
|
||||
SceneFrameContext,
|
||||
SceneInstance,
|
||||
SceneParams,
|
||||
SceneViewport
|
||||
} from "./types";
|
||||
|
||||
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const stringHash = (input: string) => {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
export const seededUnit = (seed: string, offset = 0) => {
|
||||
const hash = stringHash(`${seed}:${offset}`);
|
||||
return (hash % 10_000) / 10_000;
|
||||
};
|
||||
|
||||
export const seededSigned = (seed: string, offset = 0) => seededUnit(seed, offset) * 2 - 1;
|
||||
|
||||
export const mixColor = (base: string, target: string, amount: number) =>
|
||||
`#${new THREE.Color(base).lerp(new THREE.Color(target), clamp(amount, 0, 1)).getHexString()}`;
|
||||
|
||||
export const shiftColor = (color: string, hueDegrees: number, saturation: number, lightness: number) => {
|
||||
const source = new THREE.Color(color);
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
source.getHSL(hsl);
|
||||
return `#${new THREE.Color()
|
||||
.setHSL(
|
||||
((hsl.h + hueDegrees / 360) % 1 + 1) % 1,
|
||||
clamp(hsl.s * saturation, 0, 1),
|
||||
clamp(hsl.l * lightness, 0, 1)
|
||||
)
|
||||
.getHexString()}`;
|
||||
};
|
||||
|
||||
export type ScenicPalette = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
line: string;
|
||||
ink: string;
|
||||
};
|
||||
|
||||
export const paletteFromAssets = (
|
||||
assets: LoadedPhotoAsset[],
|
||||
scenicTreatment: SceneParams["scenicTreatment"]
|
||||
): ScenicPalette => {
|
||||
const base = assets[0]?.dominantColor ?? "#9fb0c4";
|
||||
const primary = shiftColor(
|
||||
mixColor(base, "#84ddff", 0.5),
|
||||
scenicTreatment.hue,
|
||||
scenicTreatment.saturation * 1.12,
|
||||
scenicTreatment.lightness * 1.08
|
||||
);
|
||||
const secondary = shiftColor(
|
||||
mixColor(base, "#ff93dc", 0.42),
|
||||
scenicTreatment.hue + 34,
|
||||
scenicTreatment.saturation * 1.14,
|
||||
scenicTreatment.lightness * 1.06
|
||||
);
|
||||
const accent = shiftColor(
|
||||
mixColor(mixColor(primary, secondary, 0.42), "#fff6cf", 0.34),
|
||||
scenicTreatment.hue * 0.54 + 8,
|
||||
Math.max(0.88, scenicTreatment.saturation * 1.02),
|
||||
Math.max(0.88, scenicTreatment.lightness * 1.14)
|
||||
);
|
||||
const line = shiftColor("#f7f3ff", scenicTreatment.hue * 0.34, 0.9 + scenicTreatment.saturation * 0.18, 1);
|
||||
const ink = shiftColor("#05070d", scenicTreatment.hue * 0.2, 0.76, 0.64 + (scenicTreatment.lightness - 1) * 0.18);
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
accent,
|
||||
line,
|
||||
ink
|
||||
};
|
||||
};
|
||||
|
||||
export type PlaneBundle = {
|
||||
group: THREE.Group;
|
||||
image: THREE.Mesh;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type LayoutRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
width: number;
|
||||
height: number;
|
||||
yaw?: number;
|
||||
pitch?: number;
|
||||
};
|
||||
|
||||
export const createPhotoPlane = (
|
||||
asset: LoadedPhotoAsset,
|
||||
_params: SceneParams["photoTreatment"],
|
||||
options: {
|
||||
height?: number;
|
||||
opacity?: number;
|
||||
frameOpacity?: number;
|
||||
shadowOpacity?: number;
|
||||
tint?: string;
|
||||
} = {}
|
||||
): PlaneBundle => {
|
||||
const group = new THREE.Group();
|
||||
const height = options.height ?? 3;
|
||||
const width = height * clamp(asset.aspect, 0.48, 1.95);
|
||||
const hasTexture = Boolean(asset.texture);
|
||||
const fallbackColor = options.tint
|
||||
? mixColor(asset.dominantColor, options.tint, 0.35)
|
||||
: asset.dominantColor;
|
||||
|
||||
if ((options.shadowOpacity ?? 0.08) > 0) {
|
||||
const shadow = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(width + 0.18, height + 0.18),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: "#020306",
|
||||
transparent: true,
|
||||
opacity: options.shadowOpacity ?? 0.08,
|
||||
depthWrite: false,
|
||||
depthTest: false
|
||||
})
|
||||
);
|
||||
shadow.renderOrder = 18;
|
||||
shadow.position.set(0.08, -0.08, -0.08);
|
||||
group.add(shadow);
|
||||
}
|
||||
|
||||
if ((options.frameOpacity ?? 0.03) > 0) {
|
||||
const frame = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(width + 0.08, height + 0.08),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: "#f5eee5",
|
||||
transparent: true,
|
||||
opacity: options.frameOpacity ?? 0.03,
|
||||
depthWrite: false,
|
||||
depthTest: false
|
||||
})
|
||||
);
|
||||
frame.renderOrder = 19;
|
||||
frame.position.z = -0.02;
|
||||
group.add(frame);
|
||||
}
|
||||
|
||||
const imageMaterial = new THREE.MeshBasicMaterial({
|
||||
map: asset.texture,
|
||||
color: hasTexture ? "#ffffff" : fallbackColor,
|
||||
transparent: true,
|
||||
opacity: options.opacity ?? 1,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const image = new THREE.Mesh(new THREE.PlaneGeometry(width, height), imageMaterial);
|
||||
image.renderOrder = 20;
|
||||
group.add(image);
|
||||
|
||||
return {
|
||||
group,
|
||||
image,
|
||||
width,
|
||||
height
|
||||
};
|
||||
};
|
||||
|
||||
const fitPlaneHeightToRect = (asset: LoadedPhotoAsset, rect: LayoutRect) =>
|
||||
clamp(Math.min(rect.height, rect.width / clamp(asset.aspect, 0.48, 1.95)), 0.9, rect.height);
|
||||
|
||||
export const createFittedPhotoPlane = (
|
||||
asset: LoadedPhotoAsset,
|
||||
params: SceneParams["photoTreatment"],
|
||||
rect: LayoutRect,
|
||||
options: {
|
||||
opacity?: number;
|
||||
frameOpacity?: number;
|
||||
shadowOpacity?: number;
|
||||
tint?: string;
|
||||
} = {}
|
||||
) => {
|
||||
const plane = createPhotoPlane(asset, params, {
|
||||
...options,
|
||||
height: fitPlaneHeightToRect(asset, rect)
|
||||
});
|
||||
plane.group.position.set(rect.x, rect.y, rect.z);
|
||||
plane.group.rotation.x = rect.pitch ?? 0;
|
||||
plane.group.rotation.y = rect.yaw ?? 0;
|
||||
return plane;
|
||||
};
|
||||
|
||||
const applyLayoutSpread = (rects: LayoutRect[], spread: number, depth: number) => {
|
||||
const xGain = 0.92 + spread * 0.36;
|
||||
const yGain = 0.96 + spread * 0.14;
|
||||
const depthGain = 0.9 + depth * 0.45;
|
||||
return rects.map((rect, index) => ({
|
||||
...rect,
|
||||
x: rect.x * xGain,
|
||||
y: rect.y * yGain,
|
||||
z: rect.z * depthGain - index * depth * 0.08
|
||||
}));
|
||||
};
|
||||
|
||||
export const createHeroLayoutRects = (
|
||||
count: number,
|
||||
formation: SceneParams["composition"]["formation"],
|
||||
composition: SceneParams["composition"]
|
||||
) => {
|
||||
let base: LayoutRect[];
|
||||
|
||||
if (count <= 1) {
|
||||
base = [{ x: 0, y: 0.04, z: -0.9, width: 4.9, height: 4.2 }];
|
||||
} else if (count === 2) {
|
||||
base =
|
||||
formation === "arc"
|
||||
? [
|
||||
{ x: -1.05, y: 0.02, z: -0.92, width: 4.15, height: 4.12, yaw: 0.04 },
|
||||
{ x: 2.25, y: 0.7, z: -1.8, width: 2.15, height: 2.18, yaw: -0.08 }
|
||||
]
|
||||
: [
|
||||
{ x: -0.84, y: 0.04, z: -0.92, width: 4.1, height: 4.12, yaw: 0.04 },
|
||||
{ x: 2.3, y: -0.18, z: -1.72, width: 2.18, height: 2.14, yaw: -0.08 }
|
||||
];
|
||||
} else if (count === 3) {
|
||||
base =
|
||||
formation === "arc"
|
||||
? [
|
||||
{ x: -0.52, y: 0, z: -0.92, width: 3.95, height: 4.04, yaw: 0.02 },
|
||||
{ x: -2.5, y: 1.24, z: -1.9, width: 1.9, height: 1.96, yaw: 0.1 },
|
||||
{ x: 2.5, y: 1.02, z: -1.82, width: 1.92, height: 1.98, yaw: -0.1 }
|
||||
]
|
||||
: [
|
||||
{ x: -0.58, y: 0.06, z: -0.92, width: 3.92, height: 4.02, yaw: 0.02 },
|
||||
{ x: 2.48, y: 1.04, z: -1.84, width: 1.88, height: 1.92, yaw: -0.1 },
|
||||
{ x: 2.48, y: -1.02, z: -1.98, width: 1.88, height: 1.92, yaw: -0.08 }
|
||||
];
|
||||
} else {
|
||||
base =
|
||||
formation === "arc"
|
||||
? [
|
||||
{ x: -0.42, y: 0, z: -0.92, width: 3.6, height: 3.86, yaw: 0.02 },
|
||||
{ x: -2.55, y: 1.34, z: -1.86, width: 1.68, height: 1.72, yaw: 0.1 },
|
||||
{ x: 2.52, y: 1.08, z: -1.94, width: 1.68, height: 1.72, yaw: -0.1 },
|
||||
{ x: 2.36, y: -1.22, z: -2.02, width: 1.68, height: 1.72, yaw: -0.08 }
|
||||
]
|
||||
: [
|
||||
{ x: -0.74, y: 0.04, z: -0.92, width: 3.66, height: 3.84, yaw: 0.02 },
|
||||
{ x: 2.44, y: 1.36, z: -1.82, width: 1.66, height: 1.68, yaw: -0.1 },
|
||||
{ x: 2.44, y: 0, z: -1.9, width: 1.66, height: 1.68, yaw: -0.08 },
|
||||
{ x: 2.44, y: -1.36, z: -1.98, width: 1.66, height: 1.68, yaw: -0.06 }
|
||||
];
|
||||
}
|
||||
|
||||
return applyLayoutSpread(base, composition.spread, composition.depth);
|
||||
};
|
||||
|
||||
export const createEqualLayoutRects = (
|
||||
count: number,
|
||||
formation: SceneParams["composition"]["formation"],
|
||||
composition: SceneParams["composition"]
|
||||
) => {
|
||||
let base: LayoutRect[];
|
||||
|
||||
if (count <= 1) {
|
||||
base = [{ x: 0, y: 0.04, z: -1, width: 4.6, height: 4 }];
|
||||
} else if (count === 2) {
|
||||
if (formation === "arc") {
|
||||
base = [
|
||||
{ x: -1.9, y: 0.62, z: -1.08, width: 2.7, height: 3.1, yaw: 0.08 },
|
||||
{ x: 1.9, y: -0.38, z: -1.18, width: 2.7, height: 3.1, yaw: -0.08 }
|
||||
];
|
||||
} else if (formation === "cluster") {
|
||||
base = [
|
||||
{ x: -1.6, y: 0.72, z: -1.02, width: 2.75, height: 3.05, yaw: 0.06 },
|
||||
{ x: 1.42, y: -0.66, z: -1.18, width: 2.75, height: 3.05, yaw: -0.06 }
|
||||
];
|
||||
} else {
|
||||
base = [
|
||||
{ x: -1.86, y: 0, z: -1.02, width: 2.9, height: 3.3, yaw: 0.04 },
|
||||
{ x: 1.86, y: 0, z: -1.12, width: 2.9, height: 3.3, yaw: -0.04 }
|
||||
];
|
||||
}
|
||||
} else if (count === 3) {
|
||||
if (formation === "line") {
|
||||
base = [
|
||||
{ x: -2.16, y: 0.16, z: -1.02, width: 2.18, height: 2.72, yaw: 0.04 },
|
||||
{ x: 0, y: 0, z: -1.1, width: 2.48, height: 3.04 },
|
||||
{ x: 2.16, y: -0.18, z: -1.18, width: 2.18, height: 2.72, yaw: -0.04 }
|
||||
];
|
||||
} else if (formation === "arc" || formation === "cluster") {
|
||||
base = [
|
||||
{ x: 0, y: 1.08, z: -1.02, width: 2.46, height: 2.72 },
|
||||
{ x: -1.96, y: -1.02, z: -1.12, width: 2.18, height: 2.62, yaw: 0.06 },
|
||||
{ x: 1.96, y: -0.92, z: -1.2, width: 2.18, height: 2.62, yaw: -0.06 }
|
||||
];
|
||||
} else {
|
||||
base = [
|
||||
{ x: 0, y: 1.02, z: -1.02, width: 2.4, height: 2.68 },
|
||||
{ x: -1.88, y: -1.04, z: -1.14, width: 2.22, height: 2.62 },
|
||||
{ x: 1.88, y: -1.04, z: -1.22, width: 2.22, height: 2.62 }
|
||||
];
|
||||
}
|
||||
} else {
|
||||
if (formation === "cluster") {
|
||||
base = [
|
||||
{ x: -1.82, y: 1.06, z: -1.02, width: 2.08, height: 2.28 },
|
||||
{ x: 1.58, y: 1.22, z: -1.1, width: 2.08, height: 2.28 },
|
||||
{ x: -1.46, y: -1.14, z: -1.18, width: 2.08, height: 2.28 },
|
||||
{ x: 1.9, y: -0.98, z: -1.26, width: 2.08, height: 2.28 }
|
||||
];
|
||||
} else if (formation === "ribbon") {
|
||||
base = [
|
||||
{ x: -2.64, y: 0.96, z: -1.02, width: 1.92, height: 2.24, yaw: 0.05 },
|
||||
{ x: -0.88, y: -0.16, z: -1.1, width: 1.92, height: 2.24, yaw: 0.02 },
|
||||
{ x: 0.88, y: 0.26, z: -1.18, width: 1.92, height: 2.24, yaw: -0.02 },
|
||||
{ x: 2.64, y: -0.88, z: -1.26, width: 1.92, height: 2.24, yaw: -0.05 }
|
||||
];
|
||||
} else {
|
||||
base = [
|
||||
{ x: -1.84, y: 1.16, z: -1.02, width: 2.08, height: 2.26 },
|
||||
{ x: 1.84, y: 1.16, z: -1.1, width: 2.08, height: 2.26 },
|
||||
{ x: -1.84, y: -1.16, z: -1.18, width: 2.08, height: 2.26 },
|
||||
{ x: 1.84, y: -1.16, z: -1.26, width: 2.08, height: 2.26 }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return applyLayoutSpread(base, composition.spread, composition.depth);
|
||||
};
|
||||
|
||||
export const createArrivalLayoutRects = (
|
||||
count: number,
|
||||
mode: string,
|
||||
composition: SceneParams["composition"]
|
||||
) => {
|
||||
const base: LayoutRect[] =
|
||||
count <= 1
|
||||
? [{ x: mode === "relay_rail" ? 0.96 : 0.72, y: 0.02, z: -0.92, width: 4.2, height: 4.06, yaw: -0.04 }]
|
||||
: count === 2
|
||||
? [
|
||||
{ x: 1.18, y: 0.02, z: -0.92, width: 3.72, height: 3.84, yaw: -0.04 },
|
||||
{ x: -2.24, y: 0.74, z: -1.66, width: 1.88, height: 1.98, yaw: 0.08 }
|
||||
]
|
||||
: count === 3
|
||||
? [
|
||||
{ x: 1.18, y: 0.02, z: -0.92, width: 3.68, height: 3.82, yaw: -0.04 },
|
||||
{ x: -2.3, y: 1.18, z: -1.66, width: 1.82, height: 1.92, yaw: 0.08 },
|
||||
{ x: -2.3, y: -1.04, z: -1.76, width: 1.82, height: 1.92, yaw: 0.08 }
|
||||
]
|
||||
: [
|
||||
{ x: 1.22, y: 0.04, z: -0.92, width: 3.54, height: 3.7, yaw: -0.04 },
|
||||
{ x: -2.42, y: 1.56, z: -1.66, width: 1.68, height: 1.74, yaw: 0.08 },
|
||||
{ x: -2.42, y: 0, z: -1.78, width: 1.68, height: 1.74, yaw: 0.08 },
|
||||
{ x: -2.42, y: -1.56, z: -1.9, width: 1.68, height: 1.74, yaw: 0.08 }
|
||||
];
|
||||
|
||||
return applyLayoutSpread(base, composition.spread * 0.8, composition.depth * 0.7);
|
||||
};
|
||||
|
||||
export type FieldBundle = {
|
||||
group: THREE.Group;
|
||||
uniforms: FieldUniforms[];
|
||||
};
|
||||
|
||||
export type FieldUniforms = {
|
||||
uTime: { value: number };
|
||||
uType: { value: number };
|
||||
uIntensity: { value: number };
|
||||
uScale: { value: number };
|
||||
uSpeed: { value: number };
|
||||
uAspect: { value: number };
|
||||
uPrimary: { value: THREE.Color };
|
||||
uSecondary: { value: THREE.Color };
|
||||
uAccent: { value: THREE.Color };
|
||||
uInk: { value: THREE.Color };
|
||||
};
|
||||
|
||||
const FIELD_VERTEX_SHADER = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FIELD_FRAGMENT_SHADER = `
|
||||
varying vec2 vUv;
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uType;
|
||||
uniform float uIntensity;
|
||||
uniform float uScale;
|
||||
uniform float uSpeed;
|
||||
uniform float uAspect;
|
||||
uniform vec3 uPrimary;
|
||||
uniform vec3 uSecondary;
|
||||
uniform vec3 uAccent;
|
||||
uniform vec3 uInk;
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
value += amplitude * noise(p);
|
||||
p = p * 2.02 + vec2(14.7, 9.2);
|
||||
amplitude *= 0.52;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
vec2 rotate2d(vec2 p, float angle) {
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
return mat2(c, -s, s, c) * p;
|
||||
}
|
||||
|
||||
float sparkleField(vec2 p, float t) {
|
||||
float sparkle = noise(p * 6.5 + vec2(t * 0.16, -t * 0.12));
|
||||
sparkle *= noise(p * 11.0 - vec2(t * 0.22, t * 0.18));
|
||||
return smoothstep(0.73, 0.98, sparkle);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv * 2.0 - 1.0;
|
||||
uv.x *= uAspect;
|
||||
float t = uTime * uSpeed;
|
||||
vec2 p = uv * (0.8 + uScale);
|
||||
|
||||
float mask = 0.0;
|
||||
vec3 field = uInk;
|
||||
float glow = 0.0;
|
||||
float sparkle = 0.0;
|
||||
|
||||
if (uType < 0.5) {
|
||||
vec2 q = p + vec2(fbm(p * 0.75 + t * 0.14), fbm(p * 0.92 - t * 0.12));
|
||||
float n = fbm(q + vec2(t * 0.22, -t * 0.12));
|
||||
float m = fbm(rotate2d(q * 1.42, 0.34) + vec2(-t * 0.14, t * 0.18));
|
||||
mask = smoothstep(0.18, 0.98, n * 0.76 + m * 0.56);
|
||||
glow = smoothstep(0.52, 0.96, m) * 0.42;
|
||||
sparkle = sparkleField(q, t) * 0.14;
|
||||
field = mix(uInk, mix(uPrimary, uSecondary, m), mask);
|
||||
} else if (uType < 1.5) {
|
||||
vec2 q = rotate2d(p, 0.18);
|
||||
float waveA = sin((q.x + fbm(q * 0.9)) * 7.2 + t * 1.5) * 0.5 + 0.5;
|
||||
float waveB = sin((q.y - fbm(q * 1.15)) * 9.1 - t * 1.2) * 0.5 + 0.5;
|
||||
float caustic = pow(clamp(waveA * waveB + fbm(q * 1.6) * 0.16, 0.0, 1.0), 1.4);
|
||||
mask = smoothstep(0.16, 0.98, caustic);
|
||||
glow = smoothstep(0.58, 0.98, caustic) * 0.56;
|
||||
sparkle = sparkleField(q * 1.2, t) * 0.12;
|
||||
field = mix(uInk, mix(uPrimary, uAccent, waveA), mask);
|
||||
} else if (uType < 2.5) {
|
||||
vec2 q = rotate2d(p, 0.42);
|
||||
float lattice = abs(sin(q.x * 8.4 + t * 1.1)) + abs(sin(q.y * 9.6 - t * 1.24));
|
||||
float diagonals = abs(sin((q.x + q.y) * 6.4 + t * 0.7)) + abs(sin((q.x - q.y) * 5.6 - t * 0.64));
|
||||
float mesh = smoothstep(0.62, 1.72, lattice * 0.76 + diagonals * 0.42 + fbm(q * 2.0) * 0.14);
|
||||
mask = mesh;
|
||||
glow = smoothstep(0.54, 1.0, mesh) * 0.44;
|
||||
sparkle = sparkleField(q * 1.4, t) * 0.18;
|
||||
field = mix(uInk, mix(uPrimary, uAccent, mesh), mesh);
|
||||
} else if (uType < 3.5) {
|
||||
vec2 q = p * 0.88 + vec2(fbm(p * 0.6 + t * 0.05), fbm(p * 0.6 - t * 0.05)) * 0.35;
|
||||
float n = fbm(q - vec2(t * 0.15, t * 0.12));
|
||||
float radial = 1.0 - clamp(length(uv) * 0.84, 0.0, 1.0);
|
||||
float pressure = smoothstep(0.16, 0.96, n * 0.74 + radial * 0.44);
|
||||
mask = pressure;
|
||||
glow = radial * 0.34 + smoothstep(0.62, 0.98, n) * 0.22;
|
||||
sparkle = sparkleField(q * 0.9, t) * radial * 0.1;
|
||||
field = mix(uInk, mix(uPrimary, uSecondary, radial), mask);
|
||||
} else if (uType < 4.5) {
|
||||
float radius = length(uv);
|
||||
float rings = sin(radius * 20.0 - t * 2.2) * 0.5 + 0.5;
|
||||
float haze = fbm(p * 1.1 + vec2(t * 0.08, -t * 0.06));
|
||||
float starburst = pow(abs(cos(atan(uv.y, uv.x) * 6.0)) * 0.5 + 0.5, 7.0) * (1.0 - smoothstep(0.08, 1.24, radius));
|
||||
mask = smoothstep(0.16, 0.98, (1.0 - radius) * 0.5 + rings * 0.34 + haze * 0.22 + starburst * 0.42);
|
||||
glow = starburst * 0.62 + smoothstep(0.58, 0.98, rings) * 0.24;
|
||||
sparkle = sparkleField(p * 1.05, t) * 0.16;
|
||||
field = mix(uInk, mix(uPrimary, uAccent, rings), mask);
|
||||
} else if (uType < 5.5) {
|
||||
float n = fbm(p * 0.65 + vec2(t * 0.05, -t * 0.04));
|
||||
mask = smoothstep(0.28, 0.86, n);
|
||||
glow = smoothstep(0.7, 0.96, n) * 0.12;
|
||||
sparkle = sparkleField(p * 0.8, t) * 0.06;
|
||||
field = mix(uInk, mix(uPrimary, uSecondary, 0.35), mask * 0.7);
|
||||
} else if (uType < 6.5) {
|
||||
vec2 q = rotate2d(p, 0.78);
|
||||
float crystalA = abs(sin(q.x * 6.8 + fbm(q * 1.2) * 3.2 + t * 0.88));
|
||||
float crystalB = abs(sin(q.y * 8.6 - fbm(q * 1.4) * 2.6 - t * 1.04));
|
||||
float bloom = smoothstep(0.62, 1.36, crystalA + crystalB + fbm(q * 2.2) * 0.28);
|
||||
mask = bloom;
|
||||
glow = smoothstep(0.54, 0.98, bloom) * 0.48;
|
||||
sparkle = sparkleField(q * 1.5, t) * 0.22;
|
||||
field = mix(uInk, mix(uSecondary, uAccent, crystalA), mask);
|
||||
} else if (uType < 7.5) {
|
||||
vec2 q = p;
|
||||
float ribbonA = sin((q.y + fbm(q * 0.8) * 0.8) * 5.2 + t * 1.28);
|
||||
float ribbonB = sin((q.y * 1.4 - q.x * 0.3) * 7.1 - t * 1.02 + fbm(q * 1.1));
|
||||
float current = smoothstep(-0.12, 0.86, ribbonA * 0.58 + ribbonB * 0.42 + fbm(q * 1.4) * 0.32);
|
||||
mask = current;
|
||||
glow = smoothstep(0.58, 0.96, current) * 0.52;
|
||||
sparkle = sparkleField(q * 1.1, t) * 0.16;
|
||||
field = mix(uInk, mix(uPrimary, uSecondary, ribbonB * 0.5 + 0.5), mask);
|
||||
} else {
|
||||
vec2 q = rotate2d(p, 0.58);
|
||||
float cellA = abs(sin(q.x * 9.0 + t * 0.92));
|
||||
float cellB = abs(sin(q.y * 9.8 - t * 0.84));
|
||||
float shimmer = smoothstep(0.74, 1.52, cellA * 0.8 + cellB * 0.84 + fbm(q * 2.4) * 0.22);
|
||||
mask = shimmer;
|
||||
glow = smoothstep(0.62, 1.0, shimmer) * 0.46;
|
||||
sparkle = sparkleField(q * 1.8, t) * 0.2;
|
||||
field = mix(uInk, mix(uPrimary, uAccent, shimmer), shimmer);
|
||||
}
|
||||
|
||||
float vignette = smoothstep(1.48, 0.18, length(uv));
|
||||
float scenicMix = clamp(uIntensity * (mask * 0.88 + glow * 0.44) * vignette, 0.0, 1.0);
|
||||
vec3 color = mix(uInk, field, scenicMix);
|
||||
color += mix(uSecondary, uAccent, 0.65) * glow * uIntensity * 0.26;
|
||||
color += uAccent * sparkle * uIntensity * 0.42;
|
||||
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fieldTypeToValue = (fieldType: ScenicFieldType) => {
|
||||
switch (fieldType) {
|
||||
case "stardust_drift":
|
||||
return 0;
|
||||
case "nebula_veil":
|
||||
return 3;
|
||||
case "crystal_caustic":
|
||||
return 6;
|
||||
case "geode_bloom":
|
||||
return 4;
|
||||
case "aurora_mesh":
|
||||
return 7;
|
||||
case "void_shimmer":
|
||||
return 8;
|
||||
case "quiet_ether":
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
};
|
||||
|
||||
const createFieldPlane = (
|
||||
palette: ScenicPalette,
|
||||
scenicTreatment: SceneParams["scenicTreatment"],
|
||||
viewport: SceneViewport,
|
||||
options: {
|
||||
z: number;
|
||||
scale: number;
|
||||
intensity: number;
|
||||
speed: number;
|
||||
colorMix?: number;
|
||||
opacity?: number;
|
||||
renderOrder?: number;
|
||||
blending?: THREE.Blending;
|
||||
}
|
||||
) => {
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uType: { value: fieldTypeToValue(scenicTreatment.fieldType) },
|
||||
uIntensity: { value: clamp(options.intensity, 0, 1) },
|
||||
uScale: { value: scenicTreatment.fieldScale * options.scale },
|
||||
uSpeed: { value: scenicTreatment.fieldSpeed * options.speed },
|
||||
uAspect: { value: viewport.aspect },
|
||||
uPrimary: { value: new THREE.Color(palette.primary) },
|
||||
uSecondary: { value: new THREE.Color(mixColor(palette.secondary, palette.primary, options.colorMix ?? 0.35)) },
|
||||
uAccent: { value: new THREE.Color(palette.accent) },
|
||||
uInk: { value: new THREE.Color(palette.ink) }
|
||||
};
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: FIELD_VERTEX_SHADER,
|
||||
fragmentShader: FIELD_FRAGMENT_SHADER,
|
||||
transparent: (options.opacity ?? 1) < 1,
|
||||
opacity: options.opacity ?? 1,
|
||||
blending: options.blending ?? THREE.NormalBlending,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const plane = new THREE.Mesh(new THREE.PlaneGeometry(24, 14), material);
|
||||
plane.renderOrder = options.renderOrder ?? -100;
|
||||
plane.position.z = options.z;
|
||||
return { plane, uniforms };
|
||||
};
|
||||
|
||||
export const createAccentRail = (width: number, height: number, color: string, opacity: number, z: number) =>
|
||||
new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(width, height),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
);
|
||||
|
||||
export const createAccentRing = (radius: number, thickness: number, color: string, opacity: number) =>
|
||||
new THREE.Mesh(
|
||||
new THREE.RingGeometry(Math.max(radius - thickness, 0.05), radius, 96),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
);
|
||||
|
||||
export const buildBackdropSystem = (input: SceneActivationInput, palette: ScenicPalette): FieldBundle => {
|
||||
const group = new THREE.Group();
|
||||
const far = createFieldPlane(palette, input.params.scenicTreatment, input.viewport, {
|
||||
z: -13.2,
|
||||
scale: 0.96,
|
||||
intensity: input.params.scenicTreatment.fieldIntensity,
|
||||
speed: 0.34,
|
||||
colorMix: 0.24
|
||||
});
|
||||
const mid = createFieldPlane(palette, input.params.scenicTreatment, input.viewport, {
|
||||
z: -10.4,
|
||||
scale: 1.24,
|
||||
intensity: input.params.scenicTreatment.fieldIntensity * 0.72,
|
||||
speed: 0.52,
|
||||
colorMix: 0.48,
|
||||
opacity: 0.88
|
||||
});
|
||||
const shimmer = createFieldPlane(palette, input.params.scenicTreatment, input.viewport, {
|
||||
z: -8.8,
|
||||
scale: 1.44,
|
||||
intensity: input.params.scenicTreatment.fieldIntensity * 0.36,
|
||||
speed: 0.76,
|
||||
colorMix: 0.68,
|
||||
opacity: 0.38,
|
||||
renderOrder: -96,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
group.add(far.plane, mid.plane, shimmer.plane);
|
||||
|
||||
const vignette = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(24, 14),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: palette.ink,
|
||||
transparent: true,
|
||||
opacity: 0.1 + input.params.scenicTreatment.depthFog * 0.14,
|
||||
depthWrite: false,
|
||||
depthTest: false
|
||||
})
|
||||
);
|
||||
vignette.renderOrder = -90;
|
||||
vignette.position.z = -9.8;
|
||||
group.add(vignette);
|
||||
|
||||
return {
|
||||
group,
|
||||
uniforms: [far.uniforms, mid.uniforms, shimmer.uniforms]
|
||||
};
|
||||
};
|
||||
|
||||
export const updateBackdropSystem = (
|
||||
bundle: FieldBundle,
|
||||
context: SceneFrameContext,
|
||||
palette: ScenicPalette,
|
||||
scenicTreatment: SceneParams["scenicTreatment"]
|
||||
) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
bundle.uniforms.forEach((uniforms, index) => {
|
||||
uniforms.uTime.value = time * (0.8 + index * 0.18);
|
||||
uniforms.uType.value = fieldTypeToValue(scenicTreatment.fieldType);
|
||||
uniforms.uIntensity.value = clamp(
|
||||
scenicTreatment.fieldIntensity * (index === 0 ? 1 : index === 1 ? 0.72 : 0.36),
|
||||
0,
|
||||
1
|
||||
);
|
||||
uniforms.uScale.value = scenicTreatment.fieldScale * (index === 0 ? 0.96 : index === 1 ? 1.24 : 1.44);
|
||||
uniforms.uSpeed.value = scenicTreatment.fieldSpeed * (index === 0 ? 0.34 : index === 1 ? 0.52 : 0.76);
|
||||
uniforms.uPrimary.value.set(palette.primary);
|
||||
uniforms.uSecondary.value.set(
|
||||
mixColor(palette.secondary, palette.primary, index === 0 ? 0.24 : index === 1 ? 0.48 : 0.68)
|
||||
);
|
||||
uniforms.uAccent.value.set(palette.accent);
|
||||
uniforms.uInk.value.set(palette.ink);
|
||||
});
|
||||
};
|
||||
|
||||
export const truncateTextFragment = (value: string, maxLength: number) => {
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
||||
};
|
||||
|
||||
const wrapTextLines = (
|
||||
context: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
maxLines: number
|
||||
) => {
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let current = words[0] ?? "";
|
||||
for (const word of words.slice(1)) {
|
||||
const candidate = `${current} ${word}`;
|
||||
if (context.measureText(candidate).width <= maxWidth) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
lines.push(current);
|
||||
current = word;
|
||||
if (lines.length === maxLines - 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const consumed = lines.join(" ").split(/\s+/).filter(Boolean).length;
|
||||
const tail = words.slice(consumed).join(" ");
|
||||
if (tail) {
|
||||
let trimmed = tail;
|
||||
while (trimmed.length > 0 && context.measureText(`${trimmed}…`).width > maxWidth) {
|
||||
trimmed = trimmed.slice(0, -1).trimEnd();
|
||||
}
|
||||
lines.push(trimmed ? `${trimmed}…` : tail);
|
||||
} else if (current) {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
return lines.slice(0, maxLines);
|
||||
};
|
||||
|
||||
export const createTextStrip = (
|
||||
text: string,
|
||||
options: {
|
||||
color: string;
|
||||
opacity: number;
|
||||
fontSize?: number;
|
||||
maxWidth?: number;
|
||||
backgroundColor?: string;
|
||||
backgroundOpacity?: number;
|
||||
allowWrap?: boolean;
|
||||
maxLines?: number;
|
||||
}
|
||||
) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
const mesh = createAccentRail(4, 0.5, options.color, options.opacity, 0);
|
||||
return { mesh, texture: null as THREE.Texture | null };
|
||||
}
|
||||
|
||||
const fontSize = options.fontSize ?? 42;
|
||||
const maxWidth = options.maxWidth ?? 1500;
|
||||
const font = `"IBM Plex Sans Condensed", "Aptos Narrow", "Trebuchet MS", "Segoe UI", sans-serif`;
|
||||
context.font = `600 ${fontSize}px ${font}`;
|
||||
const lines = options.allowWrap
|
||||
? wrapTextLines(context, truncateTextFragment(text, 180), maxWidth - 72, options.maxLines ?? 2)
|
||||
: [truncateTextFragment(text, 100)];
|
||||
const lineHeight = Math.ceil(fontSize * 1.16);
|
||||
const textWidth = Math.max(...lines.map((line) => context.measureText(line).width));
|
||||
const width = Math.max(280, Math.ceil(Math.min(maxWidth, textWidth + 72)));
|
||||
const height = Math.max(92, Math.ceil(lines.length * lineHeight + 44));
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = options.backgroundColor ?? "rgba(4, 6, 8, 0.7)";
|
||||
context.globalAlpha = options.backgroundOpacity ?? 0.22;
|
||||
context.beginPath();
|
||||
if (typeof context.roundRect === "function") {
|
||||
context.roundRect(0, 0, width, height, 28);
|
||||
context.fill();
|
||||
} else {
|
||||
context.fillRect(0, 0, width, height);
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
|
||||
context.font = `600 ${fontSize}px ${font}`;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.lineJoin = "round";
|
||||
context.lineWidth = Math.max(4, fontSize * 0.12);
|
||||
context.strokeStyle = "rgba(3, 5, 7, 0.96)";
|
||||
context.fillStyle = options.color;
|
||||
|
||||
const startY = height / 2 - ((lines.length - 1) * lineHeight) / 2;
|
||||
lines.forEach((line, index) => {
|
||||
const y = startY + index * lineHeight;
|
||||
context.strokeText(line, width / 2, y);
|
||||
context.fillText(line, width / 2, y);
|
||||
});
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = false;
|
||||
|
||||
const planeHeight = 0.6 + lines.length * 0.12;
|
||||
const planeWidth = planeHeight * (width / height);
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(planeWidth, planeHeight),
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: options.opacity,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
);
|
||||
|
||||
return { mesh, texture };
|
||||
};
|
||||
|
||||
export type MotionEntry = {
|
||||
group: THREE.Object3D;
|
||||
basePosition: THREE.Vector3;
|
||||
baseRotation: THREE.Euler;
|
||||
phase: number;
|
||||
travelX: number;
|
||||
travelY: number;
|
||||
orbit: number;
|
||||
pitch: number;
|
||||
yaw: number;
|
||||
};
|
||||
|
||||
export const applyMotionEntry = (
|
||||
entry: MotionEntry,
|
||||
time: number,
|
||||
motion: number,
|
||||
orbitAmount: number,
|
||||
stagger = 0.2
|
||||
) => {
|
||||
const gain = 0.55 + motion * 0.7;
|
||||
entry.group.position.x =
|
||||
entry.basePosition.x +
|
||||
Math.sin(time * (0.18 + stagger * 0.08) + entry.phase) * entry.travelX * gain;
|
||||
entry.group.position.y =
|
||||
entry.basePosition.y +
|
||||
Math.cos(time * (0.14 + stagger * 0.06) + entry.phase) * entry.travelY * (0.72 + motion * 0.42);
|
||||
entry.group.position.z =
|
||||
entry.basePosition.z +
|
||||
Math.sin(time * 0.08 + entry.phase) * entry.orbit * orbitAmount * 0.28;
|
||||
entry.group.rotation.x = entry.baseRotation.x + Math.sin(time * 0.07 + entry.phase) * entry.pitch * 0.82;
|
||||
entry.group.rotation.y = entry.baseRotation.y + Math.cos(time * 0.09 + entry.phase) * entry.yaw * 0.84;
|
||||
entry.group.rotation.z = entry.baseRotation.z + Math.sin(time * 0.06 + entry.phase) * 0.003;
|
||||
};
|
||||
|
||||
export const configureCamera = (
|
||||
camera: THREE.PerspectiveCamera,
|
||||
base: { x: number; y: number; z: number },
|
||||
lookAt: THREE.Vector3,
|
||||
cameraTravel: number,
|
||||
elapsedMs: number
|
||||
) => {
|
||||
const time = elapsedMs * 0.0001;
|
||||
camera.position.set(
|
||||
base.x + Math.sin(time * 0.56) * cameraTravel * 0.28,
|
||||
base.y + Math.cos(time * 0.4) * cameraTravel * 0.1,
|
||||
base.z + Math.sin(time * 0.32) * cameraTravel * 0.14
|
||||
);
|
||||
camera.lookAt(lookAt);
|
||||
};
|
||||
|
||||
export const combineInstances = (...instances: Array<SceneInstance | null | undefined>): SceneInstance => {
|
||||
const active = instances.filter((instance): instance is SceneInstance => Boolean(instance));
|
||||
if (active.length === 1) {
|
||||
return active[0]!;
|
||||
}
|
||||
|
||||
const root = new THREE.Group();
|
||||
active.forEach((instance) => root.add(instance.root));
|
||||
return {
|
||||
root,
|
||||
update: (context) => active.forEach((instance) => instance.update?.(context)),
|
||||
dispose: () => active.forEach((instance) => instance.dispose?.())
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ScenePlugin } from "./types";
|
||||
|
||||
const sceneLoaders = {
|
||||
"witness-float": () => import("./scenes/witness-float"),
|
||||
"portal-frame": () => import("./scenes/portal-frame"),
|
||||
"orbit-gallery": () => import("./scenes/orbit-gallery"),
|
||||
"suspension-field": () => import("./scenes/suspension-field"),
|
||||
"chorus-array": () => import("./scenes/chorus-array"),
|
||||
"equal-collage": () => import("./scenes/equal-collage"),
|
||||
"arrival-relay": () => import("./scenes/arrival-relay"),
|
||||
"safe-hold": () => import("./scenes/safe-hold")
|
||||
} as const;
|
||||
|
||||
const scenePluginCache = new Map<string, Promise<ScenePlugin>>();
|
||||
const textOverlayBuilderCache = new Map<"text-overlay", Promise<typeof import("./text-overlay")>>();
|
||||
|
||||
export const defaultScenePluginMetadata = [
|
||||
{ sceneKey: "witness-float", title: "Witness Float" },
|
||||
{ sceneKey: "portal-frame", title: "Portal Frame" },
|
||||
{ sceneKey: "orbit-gallery", title: "Orbit Gallery" },
|
||||
{ sceneKey: "suspension-field", title: "Suspension Field" },
|
||||
{ sceneKey: "chorus-array", title: "Chorus Array" },
|
||||
{ sceneKey: "equal-collage", title: "Equal Collage" },
|
||||
{ sceneKey: "arrival-relay", title: "Arrival Relay" },
|
||||
{ sceneKey: "safe-hold", title: "Safe Hold" }
|
||||
] as const satisfies ReadonlyArray<Pick<ScenePlugin, "sceneKey" | "title">>;
|
||||
|
||||
type SceneLoaderKey = keyof typeof sceneLoaders;
|
||||
|
||||
export const loadScenePlugin = (sceneKey: string): Promise<ScenePlugin> => {
|
||||
const cached = scenePluginCache.get(sceneKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const loader = sceneLoaders[sceneKey as SceneLoaderKey];
|
||||
if (!loader) {
|
||||
return Promise.reject(new Error(`Unknown render scene: ${sceneKey}`));
|
||||
}
|
||||
|
||||
const promise = loader().then((module) => module.plugin);
|
||||
scenePluginCache.set(sceneKey, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const preloadScenePlugin = (sceneKey: string) => {
|
||||
void loadScenePlugin(sceneKey).catch(() => undefined);
|
||||
};
|
||||
|
||||
export const loadTextOverlayModule = () => {
|
||||
const cacheKey = "text-overlay" as const;
|
||||
const cached = textOverlayBuilderCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const promise = import("./text-overlay");
|
||||
textOverlayBuilderCache.set(cacheKey, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const preloadTextOverlayModule = () => {
|
||||
void loadTextOverlayModule().catch(() => undefined);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
|
||||
import {
|
||||
applyMotionEntry,
|
||||
buildBackdropSystem,
|
||||
clamp,
|
||||
configureCamera,
|
||||
createAccentRail,
|
||||
createArrivalLayoutRects,
|
||||
createFittedPhotoPlane,
|
||||
type MotionEntry,
|
||||
paletteFromAssets,
|
||||
seededUnit,
|
||||
updateBackdropSystem
|
||||
} from "../scene-helpers";
|
||||
|
||||
const buildArrivalRelay = (input: SceneActivationInput): SceneInstance => {
|
||||
const { composition, photoTreatment } = input.params;
|
||||
const mode = input.modeKey ?? "edge_queue";
|
||||
const count = clamp(1 + Math.round(composition.supportCount), 1, 4);
|
||||
const assets = input.loadedAssets.slice(0, count);
|
||||
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
|
||||
const backdrop = buildBackdropSystem(input, palette);
|
||||
const root = new THREE.Group();
|
||||
root.add(backdrop.group);
|
||||
const motionEntries: MotionEntry[] = [];
|
||||
const layout = createArrivalLayoutRects(assets.length, mode, composition);
|
||||
assets.forEach((asset, index) => {
|
||||
const rect = layout[index] ?? layout.at(-1)!;
|
||||
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||
opacity: index === 0 ? 1 : 0.92,
|
||||
frameOpacity: 0.02,
|
||||
shadowOpacity: 0.06
|
||||
});
|
||||
root.add(plane.group);
|
||||
motionEntries.push({
|
||||
group: plane.group,
|
||||
basePosition: plane.group.position.clone(),
|
||||
baseRotation: plane.group.rotation.clone(),
|
||||
phase: seededUnit(asset.asset.id, 18 + index) * Math.PI * 2,
|
||||
travelX: index === 0 ? 0.06 : 0.12 + index * 0.04,
|
||||
travelY: 0.04,
|
||||
orbit: 0.06,
|
||||
pitch: 0.01,
|
||||
yaw: 0.03
|
||||
});
|
||||
});
|
||||
|
||||
const rail = createAccentRail(0.18, 9.2, palette.accent, input.params.scenicTreatment.accentIntensity * 0.16, -4.8);
|
||||
rail.position.set(-5.5, 0.1, -4.8);
|
||||
root.add(rail);
|
||||
const lower = createAccentRail(15, 0.12, palette.line, input.params.scenicTreatment.accentIntensity * 0.14, -4.9);
|
||||
lower.position.set(0, -2.8, -4.9);
|
||||
root.add(lower);
|
||||
|
||||
input.camera.position.set(0.18, 0, 7.35);
|
||||
input.camera.lookAt(0.2, 0, -3.1);
|
||||
|
||||
return {
|
||||
root,
|
||||
update: (context) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
updateBackdropSystem(
|
||||
backdrop,
|
||||
context,
|
||||
paletteFromAssets(assets, input.params.scenicTreatment),
|
||||
input.params.scenicTreatment
|
||||
);
|
||||
motionEntries.forEach((entry, index) =>
|
||||
applyMotionEntry(entry, time + index * 0.12, composition.motion * 0.82, composition.orbitAmount * 0.08, composition.stagger)
|
||||
);
|
||||
rail.position.y = Math.sin(time * 0.12) * 0.08;
|
||||
lower.position.x = Math.sin(time * 0.08) * 0.12;
|
||||
configureCamera(input.camera, { x: 0.18, y: 0, z: 7.35 }, new THREE.Vector3(0.2, 0, -3.1), composition.cameraTravel, context.elapsedMs);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const plugin: ScenePlugin = {
|
||||
sceneKey: "arrival-relay",
|
||||
title: "Arrival Relay",
|
||||
build: buildArrivalRelay
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
|
||||
import {
|
||||
applyMotionEntry,
|
||||
buildBackdropSystem,
|
||||
clamp,
|
||||
configureCamera,
|
||||
createAccentRail,
|
||||
createEqualLayoutRects,
|
||||
createFittedPhotoPlane,
|
||||
type MotionEntry,
|
||||
paletteFromAssets,
|
||||
seededUnit,
|
||||
updateBackdropSystem
|
||||
} from "../scene-helpers";
|
||||
|
||||
const buildChorusArray = (input: SceneActivationInput): SceneInstance => {
|
||||
const { composition, photoTreatment } = input.params;
|
||||
const mode = input.modeKey ?? "grid_choir";
|
||||
const count = clamp(1 + Math.round(composition.supportCount), 3, 4);
|
||||
const assets = input.loadedAssets.slice(0, count);
|
||||
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
|
||||
const backdrop = buildBackdropSystem(input, palette);
|
||||
const root = new THREE.Group();
|
||||
root.add(backdrop.group);
|
||||
const motionEntries: MotionEntry[] = [];
|
||||
const layout = createEqualLayoutRects(
|
||||
assets.length,
|
||||
mode === "ribbon_quartet" ? "ribbon" : mode === "offset_choir" ? "cluster" : "grid",
|
||||
composition
|
||||
);
|
||||
assets.forEach((asset, index) => {
|
||||
const rect = layout[index] ?? layout.at(-1)!;
|
||||
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||
frameOpacity: 0.02,
|
||||
shadowOpacity: 0.06
|
||||
});
|
||||
root.add(plane.group);
|
||||
motionEntries.push({
|
||||
group: plane.group,
|
||||
basePosition: plane.group.position.clone(),
|
||||
baseRotation: plane.group.rotation.clone(),
|
||||
phase: seededUnit(asset.asset.id, 13) * Math.PI * 2,
|
||||
travelX: 0.14 + composition.spread * 0.14,
|
||||
travelY: 0.1 + composition.stagger * 0.08,
|
||||
orbit: 0.14,
|
||||
pitch: 0.01,
|
||||
yaw: 0.04
|
||||
});
|
||||
});
|
||||
|
||||
const gridLines = [
|
||||
createAccentRail(0.12, 8.5, palette.line, 0.08, -5.2),
|
||||
createAccentRail(11.5, 0.12, palette.line, 0.08, -5.2)
|
||||
];
|
||||
gridLines[0]!.position.set(0, 0, -5.2);
|
||||
gridLines[1]!.position.set(0, 0, -5.2);
|
||||
if (mode === "ribbon_quartet") {
|
||||
gridLines[1]!.rotation.z = 0.14;
|
||||
}
|
||||
root.add(...gridLines);
|
||||
|
||||
input.camera.position.set(0, 0, 7.6);
|
||||
input.camera.lookAt(0, 0, -3.6);
|
||||
|
||||
return {
|
||||
root,
|
||||
update: (context) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
updateBackdropSystem(
|
||||
backdrop,
|
||||
context,
|
||||
paletteFromAssets(assets, input.params.scenicTreatment),
|
||||
input.params.scenicTreatment
|
||||
);
|
||||
motionEntries.forEach((entry, index) =>
|
||||
applyMotionEntry(entry, time + index * 0.2, composition.motion, composition.orbitAmount * 0.16, composition.stagger)
|
||||
);
|
||||
gridLines[0]!.position.x = Math.sin(time * 0.12) * 0.16;
|
||||
gridLines[1]!.position.y = Math.cos(time * 0.14) * 0.12;
|
||||
configureCamera(input.camera, { x: 0, y: 0, z: 7.6 }, new THREE.Vector3(0, 0, -3.6), composition.cameraTravel, context.elapsedMs);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const plugin: ScenePlugin = {
|
||||
sceneKey: "chorus-array",
|
||||
title: "Chorus Array",
|
||||
build: buildChorusArray
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
|
||||
import {
|
||||
applyMotionEntry,
|
||||
buildBackdropSystem,
|
||||
clamp,
|
||||
configureCamera,
|
||||
createAccentRing,
|
||||
createEqualLayoutRects,
|
||||
createFittedPhotoPlane,
|
||||
type MotionEntry,
|
||||
paletteFromAssets,
|
||||
seededUnit,
|
||||
updateBackdropSystem
|
||||
} from "../scene-helpers";
|
||||
|
||||
const buildEqualCollage = (input: SceneActivationInput): SceneInstance => {
|
||||
const { composition, photoTreatment } = input.params;
|
||||
const mode = input.modeKey ?? "quadrant";
|
||||
const count = clamp(1 + Math.round(composition.supportCount), 2, 4);
|
||||
const assets = input.loadedAssets.slice(0, count);
|
||||
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
|
||||
const backdrop = buildBackdropSystem(input, palette);
|
||||
const root = new THREE.Group();
|
||||
root.add(backdrop.group);
|
||||
const motionEntries: MotionEntry[] = [];
|
||||
const layout = createEqualLayoutRects(
|
||||
assets.length,
|
||||
mode === "floating_blocks" ? "cluster" : mode === "arc_cluster" ? "arc" : "grid",
|
||||
composition
|
||||
);
|
||||
assets.forEach((asset, index) => {
|
||||
const rect = layout[index] ?? layout.at(-1)!;
|
||||
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||
frameOpacity: 0.022,
|
||||
shadowOpacity: 0.06
|
||||
});
|
||||
root.add(plane.group);
|
||||
motionEntries.push({
|
||||
group: plane.group,
|
||||
basePosition: plane.group.position.clone(),
|
||||
baseRotation: plane.group.rotation.clone(),
|
||||
phase: seededUnit(asset.asset.id, 15) * Math.PI * 2,
|
||||
travelX: 0.16 + composition.spread * 0.16,
|
||||
travelY: 0.12,
|
||||
orbit: 0.16 + composition.orbitAmount * 0.14,
|
||||
pitch: 0.01,
|
||||
yaw: 0.04
|
||||
});
|
||||
});
|
||||
|
||||
const accent = createAccentRing(4.45, 0.05, palette.accent, input.params.scenicTreatment.accentIntensity * 0.18);
|
||||
accent.position.set(0, 0.1, -4.8);
|
||||
if (mode === "quadrant") {
|
||||
accent.scale.set(1.15, 0.78, 1);
|
||||
}
|
||||
root.add(accent);
|
||||
|
||||
input.camera.position.set(0, 0.04, 7.75);
|
||||
input.camera.lookAt(0, 0, -3.8);
|
||||
|
||||
return {
|
||||
root,
|
||||
update: (context) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
updateBackdropSystem(
|
||||
backdrop,
|
||||
context,
|
||||
paletteFromAssets(assets, input.params.scenicTreatment),
|
||||
input.params.scenicTreatment
|
||||
);
|
||||
motionEntries.forEach((entry, index) =>
|
||||
applyMotionEntry(entry, time + index * 0.22, composition.motion, composition.orbitAmount * 0.24, composition.stagger)
|
||||
);
|
||||
accent.rotation.z = Math.sin(time * 0.16) * 0.12;
|
||||
configureCamera(input.camera, { x: 0, y: 0.04, z: 7.75 }, new THREE.Vector3(0, 0, -3.8), composition.cameraTravel, context.elapsedMs);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const plugin: ScenePlugin = {
|
||||
sceneKey: "equal-collage",
|
||||
title: "Equal Collage",
|
||||
build: buildEqualCollage
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
|
||||
import {
|
||||
applyMotionEntry,
|
||||
buildBackdropSystem,
|
||||
clamp,
|
||||
configureCamera,
|
||||
createAccentRing,
|
||||
createFittedPhotoPlane,
|
||||
createHeroLayoutRects,
|
||||
type MotionEntry,
|
||||
paletteFromAssets,
|
||||
seededUnit,
|
||||
updateBackdropSystem
|
||||
} from "../scene-helpers";
|
||||
|
||||
const buildOrbitGallery = (input: SceneActivationInput): SceneInstance => {
|
||||
const { composition, photoTreatment } = input.params;
|
||||
const mode = input.modeKey ?? "halo_arc";
|
||||
const count = clamp(1 + Math.round(composition.supportCount), 1, 3);
|
||||
const assets = input.loadedAssets.slice(0, count);
|
||||
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
|
||||
const backdrop = buildBackdropSystem(input, palette);
|
||||
const root = new THREE.Group();
|
||||
root.add(backdrop.group);
|
||||
const motionEntries: MotionEntry[] = [];
|
||||
const layout = createHeroLayoutRects(assets.length, "arc", composition);
|
||||
assets.forEach((asset, index) => {
|
||||
const rect = layout[index] ?? layout.at(-1)!;
|
||||
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||
frameOpacity: 0.03,
|
||||
shadowOpacity: 0.09
|
||||
});
|
||||
if (mode === "mirror_sweep" && index > 0) {
|
||||
plane.group.position.x *= index % 2 === 0 ? 1 : -1;
|
||||
}
|
||||
root.add(plane.group);
|
||||
motionEntries.push({
|
||||
group: plane.group,
|
||||
basePosition: plane.group.position.clone(),
|
||||
baseRotation: plane.group.rotation.clone(),
|
||||
phase: seededUnit(asset.asset.id, 6 + index) * Math.PI * 2,
|
||||
travelX: 0.14 + index * 0.06,
|
||||
travelY: 0.08 + index * 0.04,
|
||||
orbit: 0.24 + index * 0.1,
|
||||
pitch: 0.02,
|
||||
yaw: 0.06 + index * 0.02
|
||||
});
|
||||
});
|
||||
|
||||
const ring = createAccentRing(4.9, 0.06, palette.accent, input.params.scenicTreatment.accentIntensity * 0.2);
|
||||
ring.position.set(0, 0.12, -5.1);
|
||||
root.add(ring);
|
||||
if (mode === "lantern_orbit") {
|
||||
const inner = createAccentRing(2.6, 0.04, palette.line, input.params.scenicTreatment.accentIntensity * 0.12);
|
||||
inner.position.set(0, -0.18, -4.6);
|
||||
root.add(inner);
|
||||
}
|
||||
|
||||
input.camera.position.set(0, 0, 7.45);
|
||||
input.camera.lookAt(0, 0, -3.2);
|
||||
|
||||
return {
|
||||
root,
|
||||
update: (context) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
updateBackdropSystem(
|
||||
backdrop,
|
||||
context,
|
||||
paletteFromAssets(assets, input.params.scenicTreatment),
|
||||
input.params.scenicTreatment
|
||||
);
|
||||
motionEntries.forEach((entry, index) => {
|
||||
const orbitGain = composition.orbitAmount * (mode === "lantern_orbit" ? 0.75 : 0.52);
|
||||
applyMotionEntry(entry, time + index * 0.24, composition.motion, orbitGain, composition.stagger);
|
||||
});
|
||||
ring.rotation.z = Math.sin(time * 0.18) * 0.22;
|
||||
configureCamera(input.camera, { x: 0, y: 0, z: 7.45 }, new THREE.Vector3(0, 0, -3.2), composition.cameraTravel, context.elapsedMs);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const plugin: ScenePlugin = {
|
||||
sceneKey: "orbit-gallery",
|
||||
title: "Orbit Gallery",
|
||||
build: buildOrbitGallery
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
|
||||
import {
|
||||
applyMotionEntry,
|
||||
buildBackdropSystem,
|
||||
clamp,
|
||||
configureCamera,
|
||||
createAccentRail,
|
||||
createFittedPhotoPlane,
|
||||
createHeroLayoutRects,
|
||||
type MotionEntry,
|
||||
paletteFromAssets,
|
||||
seededUnit,
|
||||
updateBackdropSystem
|
||||
} from "../scene-helpers";
|
||||
|
||||
const buildPortalFrame = (input: SceneActivationInput): SceneInstance => {
|
||||
const { composition, photoTreatment } = input.params;
|
||||
const mode = input.modeKey ?? "soft_gate";
|
||||
const count = clamp(1 + Math.round(composition.supportCount), 1, 2);
|
||||
const assets = input.loadedAssets.slice(0, count);
|
||||
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
|
||||
const backdrop = buildBackdropSystem(input, palette);
|
||||
const root = new THREE.Group();
|
||||
root.add(backdrop.group);
|
||||
const motionEntries: MotionEntry[] = [];
|
||||
const layout = createHeroLayoutRects(assets.length, mode === "fold_gate" ? "arc" : "stack", composition);
|
||||
assets.forEach((asset, index) => {
|
||||
const rect = layout[index] ?? layout.at(-1)!;
|
||||
const adjustedRect =
|
||||
mode === "monolith_aperture" && index === 0
|
||||
? { ...rect, width: rect.width * 0.92, height: rect.height * 1.14 }
|
||||
: rect;
|
||||
const plane = createFittedPhotoPlane(asset, photoTreatment, adjustedRect, {
|
||||
opacity: index === 0 ? 1 : 0.94,
|
||||
frameOpacity: index === 0 ? 0.04 : 0.025,
|
||||
shadowOpacity: index === 0 ? 0.12 : 0.08
|
||||
});
|
||||
root.add(plane.group);
|
||||
motionEntries.push({
|
||||
group: plane.group,
|
||||
basePosition: plane.group.position.clone(),
|
||||
baseRotation: plane.group.rotation.clone(),
|
||||
phase: seededUnit(asset.asset.id, 3 + index) * Math.PI * 2,
|
||||
travelX: index === 0 ? 0.1 : 0.14,
|
||||
travelY: index === 0 ? 0.06 : 0.08,
|
||||
orbit: 0.08,
|
||||
pitch: 0.015,
|
||||
yaw: index === 0 ? 0.03 : 0.04
|
||||
});
|
||||
});
|
||||
|
||||
const leftFrame = createAccentRail(0.18, 8.6, palette.line, 0.1 + input.params.scenicTreatment.accentIntensity * 0.08, -4.5);
|
||||
const rightFrame = createAccentRail(0.18, 8.6, palette.line, 0.1 + input.params.scenicTreatment.accentIntensity * 0.08, -4.5);
|
||||
leftFrame.position.set(-3.45, 0, -4.5);
|
||||
rightFrame.position.set(3.45, 0, -4.5);
|
||||
root.add(leftFrame, rightFrame);
|
||||
|
||||
const topFrame = createAccentRail(7.3, 0.18, palette.accent, 0.12 + input.params.scenicTreatment.accentIntensity * 0.1, -4.4);
|
||||
topFrame.position.set(0, 3.2, -4.4);
|
||||
root.add(topFrame);
|
||||
|
||||
if (mode === "fold_gate") {
|
||||
leftFrame.rotation.y = 0.38;
|
||||
rightFrame.rotation.y = -0.38;
|
||||
} else if (mode === "monolith_aperture") {
|
||||
leftFrame.scale.y = 1.22;
|
||||
rightFrame.scale.y = 1.22;
|
||||
topFrame.position.y = 3.8;
|
||||
}
|
||||
|
||||
input.camera.position.set(0, 0.08, 6.85);
|
||||
input.camera.lookAt(0, 0, -2.8);
|
||||
|
||||
return {
|
||||
root,
|
||||
update: (context) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
updateBackdropSystem(
|
||||
backdrop,
|
||||
context,
|
||||
paletteFromAssets(assets, input.params.scenicTreatment),
|
||||
input.params.scenicTreatment
|
||||
);
|
||||
motionEntries.forEach((entry, index) =>
|
||||
applyMotionEntry(entry, time + index * 0.12, composition.motion, composition.orbitAmount * 0.22, composition.stagger)
|
||||
);
|
||||
leftFrame.position.x = -3.45 + Math.sin(time * 0.16) * 0.08;
|
||||
rightFrame.position.x = 3.45 - Math.sin(time * 0.16) * 0.08;
|
||||
configureCamera(input.camera, { x: 0, y: 0.08, z: 6.85 }, new THREE.Vector3(0, 0, -2.8), composition.cameraTravel, context.elapsedMs);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const plugin: ScenePlugin = {
|
||||
sceneKey: "portal-frame",
|
||||
title: "Portal Frame",
|
||||
build: buildPortalFrame
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
|
||||
import {
|
||||
buildBackdropSystem,
|
||||
configureCamera,
|
||||
createFittedPhotoPlane,
|
||||
paletteFromAssets,
|
||||
updateBackdropSystem
|
||||
} from "../scene-helpers";
|
||||
|
||||
const buildSafeHold = (input: SceneActivationInput): SceneInstance => {
|
||||
const assets = input.loadedAssets.slice(0, 1);
|
||||
const backdrop = buildBackdropSystem(input, paletteFromAssets(assets, input.params.scenicTreatment));
|
||||
const root = new THREE.Group();
|
||||
root.add(backdrop.group);
|
||||
let plane = null;
|
||||
if (assets[0]) {
|
||||
plane = createFittedPhotoPlane(
|
||||
assets[0],
|
||||
input.params.photoTreatment,
|
||||
{
|
||||
x: 0,
|
||||
y: -0.06,
|
||||
z: -1.6,
|
||||
width: 4.4,
|
||||
height: 3.9
|
||||
},
|
||||
{
|
||||
opacity: 0.58,
|
||||
frameOpacity: 0.015,
|
||||
shadowOpacity: 0.04
|
||||
}
|
||||
);
|
||||
root.add(plane.group);
|
||||
}
|
||||
|
||||
input.camera.position.set(0, 0, 7.2);
|
||||
input.camera.lookAt(0, 0, -3.2);
|
||||
|
||||
return {
|
||||
root,
|
||||
update: (context) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
updateBackdropSystem(
|
||||
backdrop,
|
||||
context,
|
||||
paletteFromAssets(assets, input.params.scenicTreatment),
|
||||
input.params.scenicTreatment
|
||||
);
|
||||
if (plane) {
|
||||
plane.group.position.y = -0.06 + Math.cos(time * 0.2) * 0.05;
|
||||
}
|
||||
configureCamera(
|
||||
input.camera,
|
||||
{ x: 0, y: 0, z: 7.2 },
|
||||
new THREE.Vector3(0, 0, -3.2),
|
||||
input.params.composition.cameraTravel * 0.4,
|
||||
context.elapsedMs
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const plugin: ScenePlugin = {
|
||||
sceneKey: "safe-hold",
|
||||
title: "Safe Hold",
|
||||
build: buildSafeHold
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
|
||||
import {
|
||||
applyMotionEntry,
|
||||
buildBackdropSystem,
|
||||
clamp,
|
||||
configureCamera,
|
||||
createAccentRail,
|
||||
createFittedPhotoPlane,
|
||||
createHeroLayoutRects,
|
||||
type MotionEntry,
|
||||
paletteFromAssets,
|
||||
seededSigned,
|
||||
seededUnit,
|
||||
updateBackdropSystem
|
||||
} from "../scene-helpers";
|
||||
|
||||
const buildSuspensionField = (input: SceneActivationInput): SceneInstance => {
|
||||
const { composition, photoTreatment } = input.params;
|
||||
const mode = input.modeKey ?? "hover_shelf";
|
||||
const count = clamp(1 + Math.round(composition.supportCount), 2, 4);
|
||||
const assets = input.loadedAssets.slice(0, count);
|
||||
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
|
||||
const backdrop = buildBackdropSystem(input, palette);
|
||||
const root = new THREE.Group();
|
||||
root.add(backdrop.group);
|
||||
const motionEntries: MotionEntry[] = [];
|
||||
const layout = createHeroLayoutRects(
|
||||
assets.length,
|
||||
mode === "diagonal_relay" ? "line" : mode === "depth_table" ? "cluster" : "stack",
|
||||
composition
|
||||
);
|
||||
assets.forEach((asset, index) => {
|
||||
const rect = layout[index] ?? layout.at(-1)!;
|
||||
const adjustedRect =
|
||||
mode === "depth_table"
|
||||
? {
|
||||
...rect,
|
||||
y: rect.y + seededSigned(asset.asset.id, 10) * 0.28,
|
||||
z: rect.z - index * 0.18
|
||||
}
|
||||
: rect;
|
||||
const plane = createFittedPhotoPlane(asset, photoTreatment, adjustedRect, {
|
||||
opacity: index === 0 ? 1 : 0.94,
|
||||
frameOpacity: 0.025,
|
||||
shadowOpacity: 0.07
|
||||
});
|
||||
root.add(plane.group);
|
||||
motionEntries.push({
|
||||
group: plane.group,
|
||||
basePosition: plane.group.position.clone(),
|
||||
baseRotation: plane.group.rotation.clone(),
|
||||
phase: seededUnit(asset.asset.id, 9) * Math.PI * 2,
|
||||
travelX: 0.18 + composition.spread * 0.14,
|
||||
travelY: 0.1 + composition.stagger * 0.1,
|
||||
orbit: 0.18,
|
||||
pitch: 0.016,
|
||||
yaw: 0.05
|
||||
});
|
||||
});
|
||||
|
||||
const rail = createAccentRail(13.6, 0.12, palette.line, input.params.scenicTreatment.accentIntensity * 0.16, -4.7);
|
||||
rail.position.set(0, mode === "diagonal_relay" ? 0.2 : -0.35, -4.7);
|
||||
rail.rotation.z = mode === "diagonal_relay" ? -0.2 : 0;
|
||||
root.add(rail);
|
||||
|
||||
input.camera.position.set(0, 0, 7.25);
|
||||
input.camera.lookAt(0, 0, -3.2);
|
||||
|
||||
return {
|
||||
root,
|
||||
update: (context) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
updateBackdropSystem(
|
||||
backdrop,
|
||||
context,
|
||||
paletteFromAssets(assets, input.params.scenicTreatment),
|
||||
input.params.scenicTreatment
|
||||
);
|
||||
motionEntries.forEach((entry, index) =>
|
||||
applyMotionEntry(entry, time + index * 0.16, composition.motion, composition.orbitAmount * 0.2, composition.stagger)
|
||||
);
|
||||
rail.position.x = Math.sin(time * 0.18) * 0.24;
|
||||
configureCamera(input.camera, { x: 0, y: 0, z: 7.25 }, new THREE.Vector3(0, 0, -3.2), composition.cameraTravel, context.elapsedMs);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const plugin: ScenePlugin = {
|
||||
sceneKey: "suspension-field",
|
||||
title: "Suspension Field",
|
||||
build: buildSuspensionField
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
|
||||
import {
|
||||
applyMotionEntry,
|
||||
buildBackdropSystem,
|
||||
clamp,
|
||||
configureCamera,
|
||||
createAccentRing,
|
||||
createFittedPhotoPlane,
|
||||
createHeroLayoutRects,
|
||||
type MotionEntry,
|
||||
paletteFromAssets,
|
||||
seededUnit,
|
||||
updateBackdropSystem
|
||||
} from "../scene-helpers";
|
||||
|
||||
const buildWitnessFloat = (input: SceneActivationInput): SceneInstance => {
|
||||
const { composition, photoTreatment } = input.params;
|
||||
const mode = input.modeKey ?? "near_witness";
|
||||
const count = clamp(1 + Math.round(composition.supportCount), 1, 3);
|
||||
const assets = input.loadedAssets.slice(0, count);
|
||||
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
|
||||
const backdrop = buildBackdropSystem(input, palette);
|
||||
const root = new THREE.Group();
|
||||
root.add(backdrop.group);
|
||||
const motionEntries: MotionEntry[] = [];
|
||||
const layout = createHeroLayoutRects(
|
||||
assets.length,
|
||||
mode === "twin_witness" ? "arc" : mode === "sidecar_drift" ? "line" : "stack",
|
||||
composition
|
||||
);
|
||||
|
||||
assets.forEach((asset, index) => {
|
||||
const rect = layout[index] ?? layout.at(-1)!;
|
||||
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||
opacity: index === 0 ? 1 : 0.94,
|
||||
frameOpacity: index === 0 ? 0.04 : 0.025,
|
||||
shadowOpacity: index === 0 ? 0.12 : 0.08
|
||||
});
|
||||
if (mode === "sidecar_drift" && index === 0) {
|
||||
plane.group.rotation.y += 0.05;
|
||||
}
|
||||
root.add(plane.group);
|
||||
motionEntries.push({
|
||||
group: plane.group,
|
||||
basePosition: plane.group.position.clone(),
|
||||
baseRotation: plane.group.rotation.clone(),
|
||||
phase: seededUnit(asset.asset.id, 1 + index) * Math.PI * 2,
|
||||
travelX: index === 0 ? 0.14 : 0.18,
|
||||
travelY: index === 0 ? 0.08 : 0.12,
|
||||
orbit: index === 0 ? 0.12 : 0.16,
|
||||
pitch: 0.02,
|
||||
yaw: index === 0 ? 0.04 : 0.06
|
||||
});
|
||||
});
|
||||
|
||||
const halo = createAccentRing(3.25, 0.05, palette.accent, input.params.scenicTreatment.accentIntensity * 0.18);
|
||||
halo.position.set(0, 0.12, -4.6);
|
||||
root.add(halo);
|
||||
|
||||
input.camera.position.set(0, 0, 7.1);
|
||||
input.camera.lookAt(0, 0.04, -2.6);
|
||||
|
||||
return {
|
||||
root,
|
||||
update: (context) => {
|
||||
const time = context.elapsedMs * 0.001;
|
||||
updateBackdropSystem(
|
||||
backdrop,
|
||||
context,
|
||||
paletteFromAssets(assets, input.params.scenicTreatment),
|
||||
input.params.scenicTreatment
|
||||
);
|
||||
motionEntries.forEach((entry, index) =>
|
||||
applyMotionEntry(entry, time + index * 0.18, composition.motion, composition.orbitAmount * 0.35, composition.stagger)
|
||||
);
|
||||
halo.rotation.z = Math.sin(time * 0.22) * 0.16;
|
||||
configureCamera(input.camera, { x: 0, y: 0, z: 7.1 }, new THREE.Vector3(0, 0.04, -2.6), composition.cameraTravel, context.elapsedMs);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const plugin: ScenePlugin = {
|
||||
sceneKey: "witness-float",
|
||||
title: "Witness Float",
|
||||
build: buildWitnessFloat
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { SceneActivationInput, SceneInstance } from "./types";
|
||||
import {
|
||||
clamp,
|
||||
createTextStrip,
|
||||
paletteFromAssets,
|
||||
seededSigned,
|
||||
seededUnit,
|
||||
truncateTextFragment
|
||||
} from "./scene-helpers";
|
||||
import * as THREE from "three";
|
||||
|
||||
const buildAbstractTextTokens = (fragments: string[]) => {
|
||||
const source = fragments.join(" ").replace(/\s+/g, " ").trim().toUpperCase();
|
||||
const glyphSource = source.replace(/[^A-Z0-9]/g, "");
|
||||
const glyphs: string[] = [];
|
||||
|
||||
for (let index = 0; index < glyphSource.length && glyphs.length < 22; index += 2) {
|
||||
const size = glyphs.length % 4 === 0 ? 3 : 2;
|
||||
const token = glyphSource.slice(index, index + size).trim();
|
||||
if (token.length > 0) {
|
||||
glyphs.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (glyphs.length > 0) {
|
||||
return glyphs;
|
||||
}
|
||||
|
||||
return fragments
|
||||
.flatMap((fragment) => fragment.split(/\s+/))
|
||||
.map((fragment) => truncateTextFragment(fragment, 6).toUpperCase())
|
||||
.filter(Boolean)
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
export const shouldRenderTextOverlay = (input: SceneActivationInput) =>
|
||||
input.params.textTreatment.mode !== "off" &&
|
||||
(input.textFragments ?? []).some((value) => value.trim().length > 0);
|
||||
|
||||
export const buildTextOverlay = (input: SceneActivationInput): SceneInstance | null => {
|
||||
const mode = input.params.textTreatment.mode;
|
||||
if (mode === "off") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragments = (input.textFragments ?? []).map((value) => truncateTextFragment(value, 72)).filter(Boolean);
|
||||
const glyphs = buildAbstractTextTokens(fragments);
|
||||
if (glyphs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = paletteFromAssets(input.loadedAssets, input.params.scenicTreatment);
|
||||
const root = new THREE.Group();
|
||||
const textures: THREE.Texture[] = [];
|
||||
const animated: Array<{
|
||||
mesh: THREE.Mesh;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
rot: number;
|
||||
swayX: number;
|
||||
swayY: number;
|
||||
speed: number;
|
||||
}> = [];
|
||||
const opacity = clamp(input.params.textTreatment.opacity, 0, 0.56);
|
||||
const density = clamp(input.params.textTreatment.density, 0.08, 0.56);
|
||||
const scale = clamp(input.params.textTreatment.scale, 0.56, 0.96);
|
||||
|
||||
const addGlyph = (
|
||||
value: string,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
rot?: number;
|
||||
speed?: number;
|
||||
swayX?: number;
|
||||
swayY?: number;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
backgroundOpacity?: number;
|
||||
opacityScale?: number;
|
||||
}
|
||||
) => {
|
||||
const strip = createTextStrip(value, {
|
||||
color: options.color ?? palette.line,
|
||||
opacity: opacity * (options.opacityScale ?? 1),
|
||||
fontSize: options.fontSize,
|
||||
backgroundOpacity: options.backgroundOpacity ?? 0.02
|
||||
});
|
||||
strip.mesh.position.set(options.x, options.y, options.z);
|
||||
strip.mesh.rotation.z = options.rot ?? 0;
|
||||
strip.mesh.scale.setScalar(scale);
|
||||
strip.mesh.renderOrder = 12;
|
||||
root.add(strip.mesh);
|
||||
if (strip.texture) {
|
||||
textures.push(strip.texture);
|
||||
}
|
||||
animated.push({
|
||||
mesh: strip.mesh,
|
||||
x: options.x,
|
||||
y: options.y,
|
||||
z: options.z,
|
||||
rot: options.rot ?? 0,
|
||||
swayX: options.swayX ?? 0.06,
|
||||
swayY: options.swayY ?? 0.04,
|
||||
speed: options.speed ?? 0.00004
|
||||
});
|
||||
};
|
||||
|
||||
if (mode === "glyph_dust") {
|
||||
const count = Math.min(glyphs.length, 8 + Math.round(density * 10));
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const glyph = glyphs[index % glyphs.length]!;
|
||||
const seed = `${glyph}:${index}`;
|
||||
addGlyph(glyph, {
|
||||
x: seededSigned(seed, 1) * 5.4,
|
||||
y: seededSigned(seed, 2) * 2.8,
|
||||
z: -5.8 + seededUnit(seed, 3) * 1.4,
|
||||
rot: seededSigned(seed, 4) * 0.3,
|
||||
speed: 0.00002 + seededUnit(seed, 5) * 0.00003,
|
||||
swayX: 0.08 + seededUnit(seed, 6) * 0.12,
|
||||
swayY: 0.04 + seededUnit(seed, 7) * 0.08,
|
||||
fontSize: 16 + Math.round(seededUnit(seed, 8) * 8),
|
||||
color: index % 3 === 0 ? palette.accent : index % 2 === 0 ? palette.secondary : palette.line,
|
||||
backgroundOpacity: 0.012,
|
||||
opacityScale: 0.7
|
||||
});
|
||||
}
|
||||
} else if (mode === "constellation_trace") {
|
||||
const count = Math.min(glyphs.length, 10 + Math.round(density * 8));
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const progress = count === 1 ? 0.5 : index / Math.max(1, count - 1);
|
||||
const angle = THREE.MathUtils.lerp(-1.05, 1.05, progress);
|
||||
const radius = 4.9 + Math.sin(progress * Math.PI) * 0.9;
|
||||
addGlyph(glyphs[index % glyphs.length]!, {
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * 1.7 + Math.cos(progress * Math.PI * 2) * 0.22,
|
||||
z: -5.4 - progress * 0.9,
|
||||
rot: angle * 0.22,
|
||||
speed: 0.000018 + progress * 0.000018,
|
||||
swayX: 0.06,
|
||||
swayY: 0.05,
|
||||
fontSize: 17 + (index % 3) * 2,
|
||||
color: index % 2 === 0 ? palette.line : palette.accent,
|
||||
backgroundOpacity: 0.01,
|
||||
opacityScale: 0.74
|
||||
});
|
||||
}
|
||||
} else if (mode === "crystal_runes") {
|
||||
const columns = Math.min(6, Math.max(4, 3 + Math.round(density * 6)));
|
||||
for (let index = 0; index < columns; index += 1) {
|
||||
const token = Array.from({ length: 3 }, (_, part) => glyphs[(index * 2 + part) % glyphs.length]!).join(" ");
|
||||
const left = index % 2 === 0;
|
||||
addGlyph(token, {
|
||||
x: left ? -5.6 + index * 0.2 : 5.6 - index * 0.2,
|
||||
y: 2.4 - index * 0.9,
|
||||
z: -5.6 + index * 0.18,
|
||||
rot: left ? -Math.PI / 2 : Math.PI / 2,
|
||||
speed: 0.000014 + index * 0.000006,
|
||||
swayX: 0.03,
|
||||
swayY: 0.08,
|
||||
fontSize: 15 + (index % 2),
|
||||
color: left ? palette.secondary : palette.accent,
|
||||
backgroundOpacity: 0.008,
|
||||
opacityScale: 0.76
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
update: ({ elapsedMs }) => {
|
||||
animated.forEach((entry, index) => {
|
||||
entry.mesh.position.x = entry.x + Math.sin(elapsedMs * entry.speed + index * 0.7) * entry.swayX;
|
||||
entry.mesh.position.y = entry.y + Math.cos(elapsedMs * entry.speed * 0.84 + index * 0.42) * entry.swayY;
|
||||
entry.mesh.position.z = entry.z + Math.sin(elapsedMs * entry.speed * 0.45 + index * 0.3) * 0.08;
|
||||
entry.mesh.rotation.z = entry.rot + Math.sin(elapsedMs * entry.speed * 1.8 + index) * 0.02;
|
||||
});
|
||||
},
|
||||
dispose: () => textures.forEach((texture) => texture.dispose())
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Cue, PhotoAsset, SceneDefinition, SceneParamGroups } from "@goodgrief/shared-types";
|
||||
import type { Object3D, PerspectiveCamera, Texture } from "three";
|
||||
|
||||
export type SceneParams = SceneParamGroups;
|
||||
|
||||
export interface SurfacePresentation {
|
||||
cue?: Cue | null;
|
||||
definition: SceneDefinition;
|
||||
assets: PhotoAsset[];
|
||||
params?: SceneParams;
|
||||
effectPresetId?: string;
|
||||
modeKey?: string;
|
||||
label?: string;
|
||||
textFragments?: string[];
|
||||
anchorCaption?: string | null;
|
||||
}
|
||||
|
||||
export type SurfaceQualityProfile = "preview" | "program-monitor" | "program-output";
|
||||
|
||||
export interface LoadedPhotoAsset {
|
||||
asset: PhotoAsset;
|
||||
texture: Texture | null;
|
||||
aspect: number;
|
||||
dominantColor: string;
|
||||
sourceUrl: string | null;
|
||||
}
|
||||
|
||||
export interface SceneViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
aspect: number;
|
||||
}
|
||||
|
||||
export interface SceneActivationInput extends SurfacePresentation {
|
||||
loadedAssets: LoadedPhotoAsset[];
|
||||
params: SceneParams;
|
||||
camera: PerspectiveCamera;
|
||||
viewport: SceneViewport;
|
||||
}
|
||||
|
||||
export interface SceneFrameContext {
|
||||
elapsedMs: number;
|
||||
deltaMs: number;
|
||||
viewport: SceneViewport;
|
||||
}
|
||||
|
||||
export interface SceneInstance {
|
||||
root: Object3D;
|
||||
update?: (context: SceneFrameContext) => void;
|
||||
dispose?: () => void;
|
||||
}
|
||||
|
||||
export interface ScenePlugin {
|
||||
sceneKey: string;
|
||||
title: string;
|
||||
build(input: SceneActivationInput): SceneInstance;
|
||||
}
|
||||
|
||||
export type SceneBuilder = (input: SceneActivationInput) => SceneInstance;
|
||||
Reference in New Issue
Block a user