diff --git a/src/scene/types.ts b/src/scene/types.ts index 0a1794e..11c266e 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -3,6 +3,7 @@ import type { QualityTier } from '@/journey/motion-policy'; import type { SceneResourceBag } from '@/scene/resources'; export type LiveQualityTier = Exclude; +export type SceneDetailMode = 'full' | 'reduced'; export type SceneLifecycleState = | { kind: 'idle' } diff --git a/src/scene/world/car.ts b/src/scene/world/car.ts index 4815dc6..fa1daa2 100644 --- a/src/scene/world/car.ts +++ b/src/scene/world/car.ts @@ -1,9 +1,10 @@ import * as THREE from 'three'; import type { SceneResourceBag } from '@/scene/resources'; +import type { SceneDetailMode } from '@/scene/types'; export interface CarWorld { group: CarGroup; - setDetail(detail: 'full' | 'reduced'): void; + setDetail(detail: SceneDetailMode): void; update(speed: number, acceleration: number, elapsedSeconds: number): void; } @@ -246,12 +247,12 @@ export function createCar(resources: SceneResourceBag): CarWorld { } group.add(secondary); - let detail: 'full' | 'reduced' = 'full'; + let detail: SceneDetailMode = 'full'; let squat = 0; let squatVelocity = 0; let previousElapsed: number | null = null; - const setDetail = (nextDetail: 'full' | 'reduced'): void => { + const setDetail = (nextDetail: SceneDetailMode): void => { if (detail === nextDetail) return; detail = nextDetail; secondary.visible = detail === 'full'; diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 2cfe5b4..69c5eae 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -110,6 +110,7 @@ export async function createProceduralSceneGeneration( camera.updateProjectionMatrix(); car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full'); car.group.visible = height >= 340; + sign.setViewportAspect(camera.aspect); postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale); if (TEST_BUILD && context.quality !== 'medium') { window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({ diff --git a/src/scene/world/sign.ts b/src/scene/world/sign.ts index a21db85..686e19f 100644 --- a/src/scene/world/sign.ts +++ b/src/scene/world/sign.ts @@ -4,83 +4,215 @@ import type { SignSnapshot } from '@/journey/types'; import type { SceneResourceBag } from '@/scene/resources'; const routeLabels: Readonly> = { - start: '00 / START', - profile: '01 / PROFILE', - experience: '02 / EXPERIENCE', - infrastructure: '03 / INFRASTRUCTURE', - 'selected-builds': '04 / SELECTED BUILDS', - 'additional-work': '05 / ADDITIONAL WORK', - toolkit: '06 / TOOLKIT', - contact: '07 / DESTINATION', + start: 'Start', + profile: 'Profile', + experience: 'Experience', + infrastructure: 'Infrastructure', + 'selected-builds': 'Selected builds', + 'additional-work': 'Additional work', + toolkit: 'Toolkit', + contact: 'Contact', }; +export interface SignWorld { + group: SignGroup; + setViewportAspect(aspect: number): void; + update(sign: SignSnapshot | null): void; +} + +interface SignGroup { + visible: boolean; + position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void }; + rotation: { y: number }; + scale: { setScalar(scale: number): void }; + add(...objects: unknown[]): void; + getObjectByName(name: string): unknown; +} + export function routeLabelFor(id: SectionId): string { return routeLabels[id]; } -export function createSign(resources: SceneResourceBag) { +export function createSign(resources: SceneResourceBag): SignWorld { const group = new THREE.Group(); + group.name = 'roadside-sign'; group.visible = false; - const geometry = resources.own('geometry', new THREE.PlaneGeometry(8.4, 2.2)); + + const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({ + color: 0x07101a, + roughness: 0.7, + metalness: 0.42, + transparent: true, + opacity: 0, + })); + const frameMaterial = resources.own('material', new THREE.MeshStandardMaterial({ + color: 0x2d6f79, + emissive: 0x43cfdd, + emissiveIntensity: 0.68, + roughness: 0.38, + metalness: 0.58, + transparent: true, + opacity: 0, + })); + const postMaterial = resources.own('material', new THREE.MeshStandardMaterial({ + color: 0x172b35, + roughness: 0.64, + metalness: 0.62, + transparent: true, + opacity: 0, + })); + + const backplate = new THREE.Mesh( + resources.own('geometry', new THREE.BoxGeometry(8.2, 2.35, 0.24)), + backplateMaterial, + ); + backplate.name = 'sign-backplate'; + group.add(backplate); + const canvas = document.createElement('canvas'); - canvas.width = 768; - canvas.height = 192; + canvas.width = 1_024; + canvas.height = 256; const context = canvas.getContext('2d'); const texture = resources.own('texture', new THREE.CanvasTexture(canvas)); texture.colorSpace = THREE.SRGBColorSpace; - const material = resources.own('material', new THREE.MeshBasicMaterial({ + texture.generateMipmaps = false; + texture.minFilter = THREE.LinearFilter; + const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, - opacity: 0.96, + opacity: 0, side: THREE.DoubleSide, map: texture, + toneMapped: true, })); - const panel = new THREE.Mesh(geometry, material); - group.add(panel); + const face = new THREE.Mesh( + resources.own('geometry', new THREE.PlaneGeometry(7.72, 1.87)), + faceMaterial, + ); + face.name = 'sign-face'; + face.position.z = 0.132; + face.userData.textureUpdateCount = 0; + group.add(face); - const thrusterGeometry = resources.own('geometry', new THREE.ConeGeometry(0.18, 0.72, 8)); - const thrusterMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x44e8f5 })); - for (const x of [-3.5, 3.5]) { - const thruster = new THREE.Mesh(thrusterGeometry, thrusterMaterial); - thruster.rotation.x = Math.PI / 2; - thruster.position.set(x, -1.22, 0.08); - group.add(thruster); + const frame = new THREE.Group(); + frame.name = 'sign-frame'; + const horizontalGeometry = resources.own('geometry', new THREE.BoxGeometry(8.42, 0.12, 0.32)); + const verticalGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 2.35, 0.32)); + for (const y of [-1.175, 1.175]) { + const rail = new THREE.Mesh(horizontalGeometry, frameMaterial); + rail.position.y = y; + frame.add(rail); + } + for (const x of [-4.15, 4.15]) { + const rail = new THREE.Mesh(verticalGeometry, frameMaterial); + rail.position.x = x; + frame.add(rail); + } + group.add(frame); + + const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.17, 3.5, 0.17)); + for (const [x, side] of [[-2.9, 'left'], [2.9, 'right']] as const) { + const post = new THREE.Mesh(postGeometry, postMaterial); + post.name = `sign-post-${side}`; + post.position.set(x, -2.86, -0.08); + group.add(post); } + const fadeMaterials = [backplateMaterial, frameMaterial, postMaterial, faceMaterial]; let target: SectionId | null = null; + let narrowViewport = false; + const updateTexture = (id: SectionId): void => { if (target === id) return; target = id; - if (!context) return; - context.fillStyle = '#07111b'; - context.fillRect(0, 0, canvas.width, canvas.height); - context.strokeStyle = '#35ddeb'; - context.lineWidth = 7; - context.strokeRect(8, 8, canvas.width - 16, canvas.height - 16); - context.fillStyle = '#e8fbff'; - context.font = '600 46px ui-monospace, monospace'; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - context.fillText(routeLabelFor(id), canvas.width / 2, canvas.height / 2); + face.userData.textureUpdateCount += 1; + if (context) drawLabel(context, routeLabelFor(id), canvas.width, canvas.height); texture.needsUpdate = true; }; + const applyOpacity = (opacity: number): void => { + for (const material of fadeMaterials) material.opacity = opacity; + }; + return { group, - update(sign: SignSnapshot | null) { - group.visible = sign !== null && sign.opacity > 0; - if (!sign) return; + setViewportAspect(aspect) { + narrowViewport = !Number.isFinite(aspect) || aspect < 0.85; + }, + update(sign) { + if (!sign) { + group.visible = false; + applyOpacity(0); + return; + } + updateTexture(sign.targetId); - material.opacity = sign.opacity; - const phaseOffset = sign.phase === 'anticipate' - ? -30 - : sign.phase === 'approach' - ? -26 + sign.progress * 21 - : sign.phase === 'dwell' - ? -5 - : -5 + sign.progress * 18; - group.position.set(7.2, 4.1, phaseOffset); - group.rotation.y = -0.12; + const opacity = clamp01(sign.opacity); + applyOpacity(opacity); + group.visible = opacity > 0; + const progress = smoothstep(clamp01(sign.progress)); + const dwell = narrowViewport + ? { x: 3.1, y: 3.25, z: -20, scale: 0.68 } + : { x: 8.25, y: 3.1, z: -8.5, scale: 1 }; + const horizon = narrowViewport + ? { x: 4.2, y: 4.1, z: -88, scale: 0.2 } + : { x: 12.4, y: 5.3, z: -92, scale: 0.24 }; + + if (sign.phase === 'anticipate') { + group.position.set(horizon.x, horizon.y, horizon.z); + group.scale.setScalar(horizon.scale); + } else if (sign.phase === 'approach') { + group.position.set( + THREE.MathUtils.lerp(horizon.x, dwell.x, progress), + THREE.MathUtils.lerp(horizon.y, dwell.y, progress), + THREE.MathUtils.lerp(horizon.z, dwell.z, progress), + ); + group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress)); + } else if (sign.phase === 'dwell') { + group.position.set(dwell.x, dwell.y, dwell.z); + group.scale.setScalar(dwell.scale); + } else { + group.position.set( + dwell.x + progress * (narrowViewport ? 1.4 : 3.2), + dwell.y + progress * 0.7, + THREE.MathUtils.lerp(dwell.z, 17, progress), + ); + group.scale.setScalar(dwell.scale * THREE.MathUtils.lerp(1, 1.08, progress)); + } + group.rotation.y = narrowViewport ? -0.08 : -0.16; }, }; } + +function drawLabel( + context: CanvasRenderingContext2D, + label: string, + width: number, + height: number, +): void { + context.clearRect(0, 0, width, height); + context.fillStyle = '#07111b'; + context.strokeStyle = '#56d4df'; + context.lineWidth = 4; + context.beginPath(); + if (typeof context.roundRect === 'function') { + context.roundRect(14, 14, width - 28, height - 28, 12); + } else { + context.rect(14, 14, width - 28, height - 28); + } + context.fill(); + context.stroke(); + context.fillStyle = '#eef7f8'; + context.font = '600 58px "Geist Mono Variable", ui-monospace, monospace'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText(label, width / 2, height / 2 + 2, width - 88); +} + +function clamp01(value: number): number { + return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0; +} + +function smoothstep(value: number): number { + return value * value * (3 - 2 * value); +} diff --git a/tests/browser/journey-integration.spec.ts b/tests/browser/journey-integration.spec.ts index 3a912f1..4d353c8 100644 --- a/tests/browser/journey-integration.spec.ts +++ b/tests/browser/journey-integration.spec.ts @@ -89,7 +89,7 @@ test('draws only the independently eligible next roadside sign during passive fo const viewportHeight = window.visualViewport?.height ?? window.innerHeight; const headingTop = profile.getBoundingClientRect().top + window.scrollY; const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.8)); - return { destination, viewportHeight, expectedLabel: '01 / PROFILE' }; + return { destination, viewportHeight, expectedLabel: 'Profile' }; }); await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 }))); for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) { diff --git a/tests/helpers/scene-instrumentation.ts b/tests/helpers/scene-instrumentation.ts index 8ef9f87..83ead4d 100644 --- a/tests/helpers/scene-instrumentation.ts +++ b/tests/helpers/scene-instrumentation.ts @@ -78,13 +78,23 @@ export async function installSceneInstrumentation(page: Page): Promise { }, { once: true }); const nativeFillText = CanvasRenderingContext2D.prototype.fillText; + const routeLabels = new Set([ + 'Start', + 'Profile', + 'Experience', + 'Infrastructure', + 'Selected builds', + 'Additional work', + 'Toolkit', + 'Contact', + ]); CanvasRenderingContext2D.prototype.fillText = function fillText( text: string, x: number, y: number, maxWidth?: number, ): void { - if (/^0[0-7] \/ /u.test(text)) state.routeLabelsDrawn.push(text); + if (routeLabels.has(text)) state.routeLabelsDrawn.push(text); if (maxWidth === undefined) nativeFillText.call(this, text, x, y); else nativeFillText.call(this, text, x, y, maxWidth); }; diff --git a/tests/unit/scene-resources.test.ts b/tests/unit/scene-resources.test.ts index d349c4b..c09474d 100644 --- a/tests/unit/scene-resources.test.ts +++ b/tests/unit/scene-resources.test.ts @@ -192,9 +192,10 @@ describe('scene render quality', () => { }); it('keeps route sign labels canonical and compact', () => { - expect(routeLabelFor('start')).toBe('00 / START'); - expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE'); - expect(routeLabelFor('contact')).toBe('07 / DESTINATION'); + expect(routeLabelFor('start')).toBe('Start'); + expect(routeLabelFor('infrastructure')).toBe('Infrastructure'); + expect(routeLabelFor('selected-builds')).toBe('Selected builds'); + expect(routeLabelFor('contact')).toBe('Contact'); }); it('keeps the chase car recognizably wide and fully inside the desktop view', () => { diff --git a/tests/unit/world-models.test.ts b/tests/unit/world-models.test.ts index 9cc659a..133af1e 100644 --- a/tests/unit/world-models.test.ts +++ b/tests/unit/world-models.test.ts @@ -15,6 +15,7 @@ import { createAtmosphere } from '@/scene/world/atmosphere'; import { createPostProcessor } from '@/scene/post-processing'; import { createCar } from '@/scene/world/car'; import { createChaseCamera } from '@/scene/world/environment'; +import { createSign, routeLabelFor } from '@/scene/world/sign'; describe('procedural world models', () => { it('scales geometry and atmosphere work monotonically by live quality tier', () => { @@ -299,6 +300,61 @@ describe('procedural world models', () => { expect(resources.snapshot()).toEqual(allocation); resources.dispose(); }); + + it('renders one physical roadside sign and updates its plain label once per target', () => { + const resources = new SceneResourceBag(); + const sign = createSign(resources); + for (const name of [ + 'sign-backplate', + 'sign-face', + 'sign-frame', + 'sign-post-left', + 'sign-post-right', + ]) { + expect(sign.group.getObjectByName(name), name).toBeDefined(); + } + const face = sign.group.getObjectByName('sign-face') as { + userData: { textureUpdateCount: number }; + material: { opacity: number }; + }; + const allocation = resources.snapshot(); + + sign.update(null); + expect((sign.group as { visible: boolean }).visible).toBe(false); + sign.update({ + targetId: 'infrastructure', + phase: 'anticipate', + progress: 0, + opacity: 0, + }); + expect((sign.group as { visible: boolean }).visible).toBe(false); + expect(face.userData.textureUpdateCount).toBe(1); + expect(routeLabelFor('infrastructure')).toBe('Infrastructure'); + + sign.update({ + targetId: 'infrastructure', + phase: 'approach', + progress: 0.5, + opacity: 0.7, + }); + expect((sign.group as { visible: boolean }).visible).toBe(true); + expect(face.userData.textureUpdateCount).toBe(1); + expect(face.material.opacity).toBeCloseTo(0.7, 6); + + const desktopX = (sign.group as { position: { x: number } }).position.x; + sign.setViewportAspect(0.6); + sign.update({ + targetId: 'selected-builds', + phase: 'dwell', + progress: 1, + opacity: 1, + }); + expect(face.userData.textureUpdateCount).toBe(2); + expect(routeLabelFor('selected-builds')).toBe('Selected builds'); + expect((sign.group as { position: { x: number } }).position.x).toBeLessThan(desktopX); + expect(resources.snapshot()).toEqual(allocation); + resources.dispose(); + }); }); function paletteSnapshot(palette: ReturnType) {