diff --git a/ui/public/scene3d/car-front.jpg b/ui/public/scene3d/car-front.jpg new file mode 100644 index 00000000..fb3aa0ca Binary files /dev/null and b/ui/public/scene3d/car-front.jpg differ diff --git a/ui/public/scene3d/car-glass.jpg b/ui/public/scene3d/car-glass.jpg new file mode 100644 index 00000000..2dc64afb Binary files /dev/null and b/ui/public/scene3d/car-glass.jpg differ diff --git a/ui/public/scene3d/car-paint.jpg b/ui/public/scene3d/car-paint.jpg new file mode 100644 index 00000000..d4360383 Binary files /dev/null and b/ui/public/scene3d/car-paint.jpg differ diff --git a/ui/public/scene3d/car-rear.jpg b/ui/public/scene3d/car-rear.jpg new file mode 100644 index 00000000..8da4322c Binary files /dev/null and b/ui/public/scene3d/car-rear.jpg differ diff --git a/ui/public/scene3d/drive-building.jpg b/ui/public/scene3d/drive-building.jpg new file mode 100644 index 00000000..e2cb02b6 Binary files /dev/null and b/ui/public/scene3d/drive-building.jpg differ diff --git a/ui/public/scene3d/drive-city.jpg b/ui/public/scene3d/drive-city.jpg new file mode 100644 index 00000000..60372980 Binary files /dev/null and b/ui/public/scene3d/drive-city.jpg differ diff --git a/ui/public/scene3d/drive-coast.jpg b/ui/public/scene3d/drive-coast.jpg new file mode 100644 index 00000000..71512d29 Binary files /dev/null and b/ui/public/scene3d/drive-coast.jpg differ diff --git a/ui/public/scene3d/drive-road.jpg b/ui/public/scene3d/drive-road.jpg new file mode 100644 index 00000000..73ba43b5 Binary files /dev/null and b/ui/public/scene3d/drive-road.jpg differ diff --git a/ui/public/scene3d/drive-tunnel.jpg b/ui/public/scene3d/drive-tunnel.jpg new file mode 100644 index 00000000..8c2c54d0 Binary files /dev/null and b/ui/public/scene3d/drive-tunnel.jpg differ diff --git a/ui/src/features/scene3d/Scene3DStage.tsx b/ui/src/features/scene3d/Scene3DStage.tsx index 3b0b34c0..0309a4a7 100644 --- a/ui/src/features/scene3d/Scene3DStage.tsx +++ b/ui/src/features/scene3d/Scene3DStage.tsx @@ -3,6 +3,7 @@ import { TextureLoader } from 'three' import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js' import { adoptCafeMaps, loadCafeMaps } from './cafeSet.ts' import { syncDressing } from './dressing.ts' +import { adoptDriveMaps, isDriveDressing, loadDriveMaps } from './driveSet.ts' import { applyLight, catalogFromClips, @@ -162,20 +163,30 @@ export const Scene3DStage = forwardRef(function Scene useEffect(() => { const world = worldRef.current if (!world) return - if (document.dressing !== 'cafe') { - world.dressingReady = true + if (document.dressing === 'cafe') { + world.dressingReady = false + syncDressing(world, 'cafe') + let gone = false + void loadCafeMaps().then(maps => { + if (!adoptCafeMaps(maps, () => !gone && worldRef.current === world)) return + syncDressing(world, 'cafe', { cafe: maps }) + world.dressingReady = true + }) + return () => { gone = true } + } + if (isDriveDressing(document.dressing)) { + world.dressingReady = false syncDressing(world, document.dressing) - return + let gone = false + void loadDriveMaps().then(maps => { + if (!adoptDriveMaps(maps, () => !gone && worldRef.current === world)) return + syncDressing(world, document.dressing, { drive: maps }) + world.dressingReady = true + }) + return () => { gone = true } } - world.dressingReady = false - syncDressing(world, 'cafe') - let gone = false - void loadCafeMaps().then(maps => { - if (!adoptCafeMaps(maps, () => !gone && worldRef.current === world)) return - syncDressing(world, 'cafe', maps) - world.dressingReady = true - }) - return () => { gone = true } + world.dressingReady = true + syncDressing(world, document.dressing) }, [document.dressing]) useEffect(() => { diff --git a/ui/src/features/scene3d/Scene3DWorkspace.tsx b/ui/src/features/scene3d/Scene3DWorkspace.tsx index 99b3dc5d..ce6ed43d 100644 --- a/ui/src/features/scene3d/Scene3DWorkspace.tsx +++ b/ui/src/features/scene3d/Scene3DWorkspace.tsx @@ -13,7 +13,7 @@ import { applyScene3DTemplate, patchScene3DSlot, SCENE3D_TEMPLATES, type Scene3D import type { Scene3DCameraFamily, Scene3DClipCatalogEntry, Scene3DDocument, Scene3DLoop, Scene3DSlot } from './types.ts' import { documentFromWorld3DRequest, listenForWorld3DWorkflow } from './world3dAgent.ts' -const FAMILIES = ['establishment', 'orbit', 'follow', 'pursuit', 'side', 'front', 'product', 'reveal', 'encounter', 'musical'] as const satisfies readonly Scene3DCameraFamily[] +const FAMILIES = ['establishment', 'orbit', 'follow', 'pursuit', 'side', 'front', 'chase', 'hood', 'wing', 'product', 'reveal', 'encounter', 'musical'] as const satisfies readonly Scene3DCameraFamily[] type Props = { width: number diff --git a/ui/src/features/scene3d/camera.ts b/ui/src/features/scene3d/camera.ts index 90f175a6..2133209a 100644 --- a/ui/src/features/scene3d/camera.ts +++ b/ui/src/features/scene3d/camera.ts @@ -66,6 +66,9 @@ export function cameraLookAtTime( duration: number, slots: readonly Scene3DSlot[] = [], ): Vec3 { + if (camera.family === 'chase' || camera.family === 'hood' || camera.family === 'wing') { + return camera.family === 'hood' ? [0, 0.82, -14] : [0, 0.7, -0.55] + } if (camera.family === 'follow' || camera.family === 'pursuit' || camera.family === 'side' || camera.family === 'front') { return slotLook(slots, 'subject_1', camera.look) } @@ -98,6 +101,17 @@ export function cameraEyeAtTime( const musicalTurns = camera.family === 'musical' ? turns * 2 : turns return orbitEye(look, radius, productHeight, s * musicalTurns * Math.PI * 2) } + if (camera.family === 'chase') { + const sway = Math.sin(sceneSeconds * 1.35) * 0.16 + return [0.28 + sway, 1.18, 5.45] + } + if (camera.family === 'hood') { + const bump = Math.sin(sceneSeconds * 10) * 0.03 + return [0, 1.04 + bump, -0.85] + } + if (camera.family === 'wing') { + return [5.15, 0.95, 1.65] + } if (camera.family === 'front') { return [look[0], look[1] - 0.08, look[2] + 4.05] } diff --git a/ui/src/features/scene3d/carMesh.ts b/ui/src/features/scene3d/carMesh.ts new file mode 100644 index 00000000..e602628a --- /dev/null +++ b/ui/src/features/scene3d/carMesh.ts @@ -0,0 +1,74 @@ +import { + BoxGeometry, + CylinderGeometry, + Group, + Mesh, + MeshStandardMaterial, + type Texture, +} from 'three' + +export type CarMaps = { + paint: Texture | null + glass: Texture | null + front: Texture | null + rear: Texture | null +} + +function skin(map: Texture | null, color: number, extra?: { roughness?: number; metalness?: number; emissive?: number }) { + return new MeshStandardMaterial({ + map: map ?? undefined, + color: map ? 0xffffff : color, + roughness: extra?.roughness ?? 0.42, + metalness: extra?.metalness ?? 0.55, + emissive: extra?.emissive ?? 0x000000, + emissiveIntensity: extra?.emissive ? 0.85 : 0, + }) +} + +function wheel(): Mesh { + const mesh = new Mesh( + new CylinderGeometry(0.33, 0.33, 0.22, 14), + new MeshStandardMaterial({ color: 0x111115, roughness: 0.92 }), + ) + mesh.rotation.z = Math.PI / 2 + return mesh +} + +export function buildCar(maps: CarMaps): { root: Group; wheels: Mesh[] } { + const root = new Group() + const paint = skin(maps.paint, 0x8a1518, { metalness: 0.72, roughness: 0.34 }) + const glass = skin(maps.glass, 0x151820, { metalness: 0.8, roughness: 0.12 }) + const front = skin(maps.front, 0x8a1518, { metalness: 0.5, roughness: 0.4 }) + const rear = skin(maps.rear, 0x8a1518, { metalness: 0.5, roughness: 0.4 }) + const body = new Mesh(new BoxGeometry(1.86, 0.5, 4.05), [paint, paint, paint, paint, rear, front]) + body.position.set(0, 0.46, 0) + root.add(body) + const cabin = new Mesh(new BoxGeometry(1.48, 0.34, 1.52), glass) + cabin.position.set(0, 0.84, 0.38) + root.add(cabin) + const shield = new Mesh(new BoxGeometry(1.42, 0.03, 0.98), glass) + shield.rotation.x = 0.58 + shield.position.set(0, 0.76, -0.38) + root.add(shield) + const spoiler = new Mesh(new BoxGeometry(1.5, 0.05, 0.28), paint) + spoiler.position.set(0, 0.78, 1.85) + root.add(spoiler) + const lamp = new MeshStandardMaterial({ color: 0xfff1c8, emissive: 0xffe8a8, emissiveIntensity: 1.2, roughness: 0.25 }) + const tail = new MeshStandardMaterial({ color: 0xff2a2a, emissive: 0xff1a1a, emissiveIntensity: 0.9, roughness: 0.35 }) + for (const x of [-0.62, 0.62]) { + const head = new Mesh(new BoxGeometry(0.22, 0.1, 0.08), lamp) + head.position.set(x, 0.48, -2.02) + root.add(head) + const back = new Mesh(new BoxGeometry(0.28, 0.1, 0.06), tail) + back.position.set(x, 0.5, 2.02) + root.add(back) + } + const wheels: Mesh[] = [] + for (const [x, z] of [[-0.82, -1.22], [0.82, -1.22], [-0.82, 1.22], [0.82, 1.22]]) { + const item = wheel() + item.position.set(x, 0.33, z) + root.add(item) + wheels.push(item) + } + return { root, wheels } +} diff --git a/ui/src/features/scene3d/document.ts b/ui/src/features/scene3d/document.ts index 261b3468..4370bc5c 100644 --- a/ui/src/features/scene3d/document.ts +++ b/ui/src/features/scene3d/document.ts @@ -81,6 +81,7 @@ export function parseScene3DDocument(raw: unknown): Scene3DDocument | null { ? value.templateId as Scene3DTemplateId : 'two-shot' const dressing = value.dressing === 'street' || value.dressing === 'space' || value.dressing === 'treadmill' || value.dressing === 'cafe' + || value.dressing === 'drive-city' || value.dressing === 'drive-coast' || value.dressing === 'drive-tunnel' ? value.dressing : undefined return { ...value, slots, templateId, dressing } as Scene3DDocument diff --git a/ui/src/features/scene3d/dressing.ts b/ui/src/features/scene3d/dressing.ts index a2f47dac..421472d3 100644 --- a/ui/src/features/scene3d/dressing.ts +++ b/ui/src/features/scene3d/dressing.ts @@ -8,6 +8,8 @@ import { type Object3D, } from 'three' import { cafeGroup, type CafeMaps } from './cafeSet.ts' +import { driveGroup, isDriveDressing, type DriveMaps } from './driveSet.ts' +import { clearDrive } from './driveMotion.ts' import type { GpuWorld } from './gpu.ts' import type { Scene3DDressing } from './types.ts' @@ -60,11 +62,24 @@ function spaceGroup(): Object3D { return root } -export function syncDressing(world: GpuWorld, kind: Scene3DDressing | undefined, maps?: CafeMaps) { +export function syncDressing( + world: GpuWorld, + kind: Scene3DDressing | undefined, + maps?: { cafe?: CafeMaps; drive?: DriveMaps }, +) { dropDressing(world) - world.floor.visible = kind !== 'space' && kind !== 'treadmill' && kind !== 'cafe' + clearDrive(world) + world.floor.visible = kind !== 'space' && kind !== 'treadmill' && kind !== 'cafe' && !isDriveDressing(kind) if (kind === 'street') world.dressing = streetGroup() if (kind === 'space') world.dressing = spaceGroup() - if (kind === 'cafe') world.dressing = cafeGroup(maps ?? { facade: null, floor: null, back: null }) + if (kind === 'cafe') world.dressing = cafeGroup(maps?.cafe ?? { facade: null, floor: null, back: null }) + if (isDriveDressing(kind)) { + const built = driveGroup(kind, maps?.drive ?? { paint: null, glass: null, front: null, rear: null, road: null, building: null }) + world.dressing = built.root + world.driveWheels = built.wheels + world.driveRoad = built.roadMap + world.driveMovers = built.movers + world.driveSpeed = 0.2 + } if (world.dressing) world.scene.add(world.dressing) } diff --git a/ui/src/features/scene3d/driveMotion.ts b/ui/src/features/scene3d/driveMotion.ts new file mode 100644 index 00000000..2794db3f --- /dev/null +++ b/ui/src/features/scene3d/driveMotion.ts @@ -0,0 +1,27 @@ +import { wrapUnit } from './backdrop.ts' +import type { GpuWorld } from './gpu.ts' + +function wrapSpan(value: number, min: number, max: number) { + const span = max - min + if (span <= 1e-6) return min + return min + wrapUnit((value - min) / span) * span +} + +export function clearDrive(world: GpuWorld) { + world.driveWheels = [] + world.driveRoad = null + world.driveMovers = [] + world.driveSpeed = 0 +} + +export function paintDrive(world: GpuWorld, sceneSeconds: number, speed: number) { + const pace = Number.isFinite(speed) ? Math.abs(speed) : 0 + if (!pace || (!world.driveWheels.length && !world.driveRoad && !world.driveMovers.length)) return + if (world.driveRoad) world.driveRoad.offset.y = wrapUnit(sceneSeconds * pace) + const roll = sceneSeconds * pace * 88 + for (const wheel of world.driveWheels) wheel.rotation.x = roll + for (const item of world.driveMovers) { + const base = Number(item.userData.baseZ) || 0 + item.position.z = wrapSpan(base + sceneSeconds * pace * 32, -24, 24) + } +} diff --git a/ui/src/features/scene3d/driveSet.ts b/ui/src/features/scene3d/driveSet.ts new file mode 100644 index 00000000..77aa836c --- /dev/null +++ b/ui/src/features/scene3d/driveSet.ts @@ -0,0 +1,181 @@ +import { + BoxGeometry, + ClampToEdgeWrapping, + Group, + IcosahedronGeometry, + LinearFilter, + Mesh, + MeshStandardMaterial, + PlaneGeometry, + RepeatWrapping, + SRGBColorSpace, + Texture, + TextureLoader, + type Object3D, +} from 'three' +import { buildCar, type CarMaps } from './carMesh.ts' +import type { Scene3DDressing } from './types.ts' + +export const DRIVE_TEXTURE_URLS = { + paint: '/scene3d/car-paint.jpg', + glass: '/scene3d/car-glass.jpg', + front: '/scene3d/car-front.jpg', + rear: '/scene3d/car-rear.jpg', + road: '/scene3d/drive-road.jpg', + building: '/scene3d/drive-building.jpg', + city: '/scene3d/drive-city.jpg', + coast: '/scene3d/drive-coast.jpg', + tunnel: '/scene3d/drive-tunnel.jpg', +} as const + +export type DriveMaps = CarMaps & { + road: Texture | null + building: Texture | null +} + +export type DriveDressing = 'drive-city' | 'drive-coast' | 'drive-tunnel' + +export function isDriveDressing(kind: Scene3DDressing | undefined): kind is DriveDressing { + return kind === 'drive-city' || kind === 'drive-coast' || kind === 'drive-tunnel' +} + +export function disposeDriveMaps(maps: DriveMaps) { + maps.paint?.dispose() + maps.glass?.dispose() + maps.front?.dispose() + maps.rear?.dispose() + maps.road?.dispose() + maps.building?.dispose() +} + +export function adoptDriveMaps(maps: DriveMaps, live: () => boolean): boolean { + if (live()) return true + disposeDriveMaps(maps) + return false +} + +function prep(texture: Texture, tile: boolean) { + texture.colorSpace = SRGBColorSpace + texture.minFilter = LinearFilter + texture.magFilter = LinearFilter + texture.generateMipmaps = false + texture.wrapS = tile ? RepeatWrapping : ClampToEdgeWrapping + texture.wrapT = tile ? RepeatWrapping : ClampToEdgeWrapping + texture.needsUpdate = true + return texture +} + +function loadOne(loader: TextureLoader, url: string, tile: boolean): Promise { + return new Promise(resolve => { + loader.load(url, texture => resolve(prep(texture, tile)), undefined, () => resolve(null)) + }) +} + +export function loadDriveMaps(): Promise { + const loader = new TextureLoader() + return Promise.all([ + loadOne(loader, DRIVE_TEXTURE_URLS.paint, true), + loadOne(loader, DRIVE_TEXTURE_URLS.glass, true), + loadOne(loader, DRIVE_TEXTURE_URLS.front, false), + loadOne(loader, DRIVE_TEXTURE_URLS.rear, false), + loadOne(loader, DRIVE_TEXTURE_URLS.road, true), + loadOne(loader, DRIVE_TEXTURE_URLS.building, true), + ]).then(([paint, glass, front, rear, road, building]) => { + if (road) road.repeat.set(1, 10) + if (building) building.repeat.set(1, 2) + if (paint) paint.repeat.set(2, 2) + return { paint, glass, front, rear, road, building } + }) +} + +function moving(object: Object3D, z: number) { + object.userData.baseZ = z + object.position.z = z + return object +} + +function addCity(strip: Group, maps: DriveMaps) { + const facade = new MeshStandardMaterial({ + map: maps.building ?? undefined, + color: maps.building ? 0xffffff : 0x2a2433, + roughness: 0.55, + metalness: 0.25, + }) + for (let i = 0; i < 8; i += 1) { + const z = -21 + i * 6 + const leftH = 3.2 + (i % 3) * 1.4 + const rightH = 4.1 + ((i + 1) % 3) * 1.2 + const left = new Mesh(new BoxGeometry(2.2, leftH, 3.4), facade) + left.position.set(-5.6, leftH / 2, 0) + strip.add(moving(left, z)) + const right = new Mesh(new BoxGeometry(2.4, rightH, 3.1), facade) + right.position.set(5.7, rightH / 2, 0) + strip.add(moving(right, z + 1.2)) + } +} + +function addCoast(strip: Group) { + const rock = new MeshStandardMaterial({ color: 0x8a6a4a, roughness: 0.95 }) + const rail = new MeshStandardMaterial({ color: 0xc9c4b8, roughness: 0.4, metalness: 0.6 }) + for (let i = 0; i < 7; i += 1) { + const z = -20 + i * 6.4 + const barrier = new Mesh(new BoxGeometry(0.12, 0.55, 5.6), rail) + barrier.position.set(-4.1, 0.28, 0) + strip.add(moving(barrier, z)) + const stone = new Mesh(new IcosahedronGeometry(0.7 + (i % 2) * 0.25, 0), rock) + stone.position.set(-6.4, 0.45, 0) + strip.add(moving(stone, z + 1.5)) + } +} + +function addTunnel(strip: Group) { + const wall = new MeshStandardMaterial({ color: 0x4a5858, roughness: 0.78 }) + const lamp = new MeshStandardMaterial({ color: 0xffc07a, emissive: 0xffaa55, emissiveIntensity: 0.8, roughness: 0.3 }) + for (let i = 0; i < 10; i += 1) { + const z = -22.5 + i * 5 + const left = new Mesh(new BoxGeometry(0.35, 3.6, 4.2), wall) + left.position.set(-4.3, 1.8, 0) + strip.add(moving(left, z)) + const right = new Mesh(new BoxGeometry(0.35, 3.6, 4.2), wall) + right.position.set(4.3, 1.8, 0) + strip.add(moving(right, z)) + const roof = new Mesh(new BoxGeometry(8.9, 0.28, 4.2), wall) + roof.position.set(0, 3.7, 0) + strip.add(moving(roof, z)) + const light = new Mesh(new BoxGeometry(0.4, 0.08, 0.4), lamp) + light.position.set(0, 3.5, 0) + strip.add(moving(light, z)) + } +} + +export function driveGroup(kind: DriveDressing, maps: DriveMaps): { root: Group; wheels: Mesh[]; roadMap: Texture | null; movers: Object3D[] } { + const root = new Group() + const strip = new Group() + const roadMat = new MeshStandardMaterial({ + map: maps.road ?? undefined, + color: maps.road ? 0xffffff : 0x222226, + roughness: 0.92, + }) + const road = new Mesh(new PlaneGeometry(7.4, 64), roadMat) + road.rotation.x = -Math.PI / 2 + road.position.y = 0.01 + root.add(road) + const shoulder = new MeshStandardMaterial({ color: 0x2a2a24, roughness: 0.95 }) + for (const x of [-5.2, 5.2]) { + const band = new Mesh(new PlaneGeometry(2.8, 64), shoulder) + band.rotation.x = -Math.PI / 2 + band.position.set(x, 0.008, 0) + root.add(band) + } + if (kind === 'drive-city') addCity(strip, maps) + if (kind === 'drive-coast') addCoast(strip) + if (kind === 'drive-tunnel') addTunnel(strip) + root.add(strip) + const car = buildCar(maps) + root.add(car.root) + const movers: Object3D[] = [] + strip.traverse(child => { + if (child.userData.baseZ != null) movers.push(child) + }) + return { root, wheels: car.wheels, roadMap: maps.road, movers } +} diff --git a/ui/src/features/scene3d/gpu.ts b/ui/src/features/scene3d/gpu.ts index c0341fad..9c0d1d5e 100644 --- a/ui/src/features/scene3d/gpu.ts +++ b/ui/src/features/scene3d/gpu.ts @@ -30,6 +30,7 @@ import { cameraEyeAtTime, cameraLookAtTime } from './camera.ts' import { scene3dClipLocalTime } from './clock.ts' import { cylinderUvOffset, isCylinderBackdrop, slotMountKey } from './backdrop.ts' import { scene3dSlotColor } from './document.ts' +import { paintDrive } from './driveMotion.ts' import type { Scene3DClipCatalogEntry, Scene3DDocument, Scene3DLight, Scene3DSlot } from './types.ts' export const CYLINDER_RADIUS = 12 @@ -62,6 +63,10 @@ export type GpuWorld = { floor: Mesh dressing: Object3D | null dressingReady: boolean + driveWheels: Mesh[] + driveRoad: Texture | null + driveMovers: Object3D[] + driveSpeed: number slots: Map } @@ -264,6 +269,8 @@ export function paintWorld(world: GpuWorld, document: Scene3DDocument, sceneSeco world.camera.lookAt(look[0], look[1], look[2]) world.camera.updateProjectionMatrix() applyLoopOffset(world, sceneSeconds) + const bg = document.slots.find(isCylinderBackdrop) + paintDrive(world, sceneSeconds, bg?.loop?.speed ?? world.driveSpeed) for (const gpu of world.slots.values()) { if (!gpu.mixer) continue const clip = gpu.animations.find((_clip: { duration?: number }, index: number) => clipMatches(gpu, index)) @@ -334,7 +341,11 @@ export function createWorld(host: HTMLDivElement, light: Scene3DLight, fov: numb ) floor.rotation.x = -Math.PI / 2 scene.add(floor) - return { renderer, scene, camera, dir, floor, dressing: null, dressingReady: true, slots: new Map() } + return { + renderer, scene, camera, dir, floor, dressing: null, dressingReady: true, + driveWheels: [], driveRoad: null, driveMovers: [], driveSpeed: 0, + slots: new Map(), + } } export function disposeWorld(world: GpuWorld) { diff --git a/ui/src/features/scene3d/templates.ts b/ui/src/features/scene3d/templates.ts index 2c5f5edd..9aada9db 100644 --- a/ui/src/features/scene3d/templates.ts +++ b/ui/src/features/scene3d/templates.ts @@ -26,6 +26,12 @@ export const SCENE3D_TEMPLATES: readonly Scene3DTemplate[] = [ { id: 'dance-orbit', camera: 'orbit', duration: 6, slots: ['subject_1', 'background'] }, { id: 'dance-stage', camera: 'musical', duration: 8, slots: ['subject_1', 'background'] }, { id: 'cafe-dance', camera: 'front', duration: 8, slots: ['subject_1'] }, + { id: 'drive-chase', camera: 'chase', duration: 8, slots: ['background'] }, + { id: 'drive-hood', camera: 'hood', duration: 8, slots: ['background'] }, + { id: 'drive-wing', camera: 'wing', duration: 8, slots: ['background'] }, + { id: 'drive-orbit', camera: 'musical', duration: 8, slots: ['background'] }, + { id: 'drive-tunnel', camera: 'hood', duration: 8, slots: ['background'] }, + { id: 'drive-hero', camera: 'chase', duration: 8, slots: ['subject_1', 'background'] }, ] const LAYOUTS: Record>>> = { @@ -90,6 +96,25 @@ const LAYOUTS: Record { const slot = emptySlot(slotId) @@ -123,7 +156,12 @@ export function applyScene3DTemplate(id: Scene3DTemplateId): Scene3DDocument { const next = pose ? { ...slot, ...pose } : slot const cylinder = CYLINDER_BY_TEMPLATE[template.id] if (cylinder && slotId === 'background') { - return { ...next, media: 'image', loop: { cylinder: true, speed: cylinder.speed } } + return { + ...next, + media: 'image', + sourceUrl: cylinder.plate ?? next.sourceUrl, + loop: { cylinder: true, speed: cylinder.speed }, + } } return next }) @@ -131,7 +169,7 @@ export function applyScene3DTemplate(id: Scene3DTemplateId): Scene3DDocument { return document } -const CYLINDER_BY_TEMPLATE: Partial> = { +const CYLINDER_BY_TEMPLATE: Partial> = { 'run-loop': { speed: -0.18 }, 'neon-run': { speed: -0.26 }, 'block-street': { speed: -0.16 }, @@ -139,6 +177,12 @@ const CYLINDER_BY_TEMPLATE: Partial 'walk-void': { speed: 0.08 }, 'dance-orbit': { speed: 0.04 }, 'dance-stage': { speed: 0.03 }, + 'drive-chase': { speed: 0.2, plate: '/scene3d/drive-city.jpg' }, + 'drive-hood': { speed: 0.24, plate: '/scene3d/drive-city.jpg' }, + 'drive-wing': { speed: 0.16, plate: '/scene3d/drive-coast.jpg' }, + 'drive-orbit': { speed: 0.12, plate: '/scene3d/drive-city.jpg' }, + 'drive-tunnel': { speed: 0.22, plate: '/scene3d/drive-tunnel.jpg' }, + 'drive-hero': { speed: 0.18, plate: '/scene3d/drive-city.jpg' }, } const DRESSING_BY_TEMPLATE: Partial> = { @@ -148,6 +192,12 @@ const DRESSING_BY_TEMPLATE: Partial { let disposed = 0 @@ -15,3 +16,17 @@ test('cafe maps are disposed when the load is no longer live', () => { disposeCafeMaps(kept) assert.equal(disposed, 3) }) + +test('drive maps are disposed when the load is no longer live', () => { + let disposed = 0 + const texture = () => ({ dispose() { disposed += 1 } }) + const maps = { paint: texture(), glass: texture(), front: texture(), rear: texture(), road: texture(), building: texture() } + assert.equal(adoptDriveMaps(maps, () => false), false) + assert.equal(disposed, 6) + disposed = 0 + const kept = { paint: texture(), glass: texture(), front: texture(), rear: texture(), road: texture(), building: texture() } + assert.equal(adoptDriveMaps(kept, () => true), true) + assert.equal(disposed, 0) + disposeDriveMaps(kept) + assert.equal(disposed, 6) +}) diff --git a/ui/tests/scene3dStage.test.mjs b/ui/tests/scene3dStage.test.mjs index 6946a5ba..da8b1a09 100644 --- a/ui/tests/scene3dStage.test.mjs +++ b/ui/tests/scene3dStage.test.mjs @@ -165,6 +165,31 @@ test('cafe-dance is a front set with a textured building, not a cylinder', () => assert.equal(restored?.dressing, 'cafe') }) +test('drive templates keep the car still and scroll the world', () => { + const chase = applyScene3DTemplate('drive-chase') + assert.equal(chase.dressing, 'drive-city') + assert.equal(chase.camera.family, 'chase') + const background = chase.slots.find(slot => slot.slot === 'background') + assert.equal(background?.loop?.cylinder, true) + assert.ok((background?.loop?.speed ?? 0) > 0) + assert.equal(background?.sourceUrl, '/scene3d/drive-city.jpg') + const eye = cameraEyeAtTime(chase.camera, 1, chase.duration, chase.slots) + const look = cameraLookAtTime(chase.camera, 1, chase.duration, chase.slots) + assert.ok(eye[2] > look[2]) + const hood = applyScene3DTemplate('drive-hood') + assert.equal(hood.camera.family, 'hood') + const wing = applyScene3DTemplate('drive-wing') + assert.equal(wing.dressing, 'drive-coast') + assert.equal(wing.camera.family, 'wing') + const tunnel = applyScene3DTemplate('drive-tunnel') + assert.equal(tunnel.dressing, 'drive-tunnel') + const hero = applyScene3DTemplate('drive-hero') + assert.equal(hero.slots[0].clip, null) + assert.ok(hero.slots[0].scale < 1) + const restored = parseScene3DDocument(JSON.parse(JSON.stringify(chase))) + assert.equal(restored?.dressing, 'drive-city') +}) + test('world3d export plan is independent of compositor layers', () => { assert.equal(evenDim(1281), 1280) assert.deepEqual(world3dExportSize(1920, 1080), { width: 1280, height: 720 })