feat: polish nightdrive road sun and signage
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 921 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 149 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "nightdrive-browser-errors/v1",
|
||||
"errors": []
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "nightdrive-browser-errors/v1",
|
||||
"errors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 331 KiB |
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"schema": "nightdrive-forward-motion-trace/v1",
|
||||
"observedViewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
"documentScroll": {
|
||||
"beforeReverse": 1800,
|
||||
"afterReverse": 1280,
|
||||
"reversed": true
|
||||
},
|
||||
"worldUniforms": {
|
||||
"count": 6,
|
||||
"allMonotonic": true,
|
||||
"allAdvancedDuringReverse": true,
|
||||
"uniforms": [
|
||||
{
|
||||
"key": "1:uDistance",
|
||||
"samples": 74,
|
||||
"start": 8.315999999999804,
|
||||
"end": 391.58227498378983,
|
||||
"minimumDelta": 2.7719999999999345,
|
||||
"monotonic": true,
|
||||
"phases": {
|
||||
"acceleration": {
|
||||
"start": 8.315999999999804,
|
||||
"end": 144.30947458162336,
|
||||
"samples": 21
|
||||
},
|
||||
"release": {
|
||||
"start": 152.04046450581518,
|
||||
"end": 196.87361420703746,
|
||||
"samples": 8
|
||||
},
|
||||
"coast": {
|
||||
"start": 202.19007442493694,
|
||||
"end": 279.49786736252463,
|
||||
"samples": 21
|
||||
},
|
||||
"reverse-scroll": {
|
||||
"start": 282.60246896668923,
|
||||
"end": 391.58227498378983,
|
||||
"samples": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "2:uDistance",
|
||||
"samples": 74,
|
||||
"start": 8.315999999999804,
|
||||
"end": 391.58227498378983,
|
||||
"minimumDelta": 2.7719999999999345,
|
||||
"monotonic": true,
|
||||
"phases": {
|
||||
"acceleration": {
|
||||
"start": 8.315999999999804,
|
||||
"end": 144.30947458162336,
|
||||
"samples": 21
|
||||
},
|
||||
"release": {
|
||||
"start": 152.04046450581518,
|
||||
"end": 196.87361420703746,
|
||||
"samples": 8
|
||||
},
|
||||
"coast": {
|
||||
"start": 202.19007442493694,
|
||||
"end": 279.49786736252463,
|
||||
"samples": 21
|
||||
},
|
||||
"reverse-scroll": {
|
||||
"start": 282.60246896668923,
|
||||
"end": 391.58227498378983,
|
||||
"samples": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "3:uFlowDistance",
|
||||
"samples": 74,
|
||||
"start": 8.315999999999804,
|
||||
"end": 391.58227498378983,
|
||||
"minimumDelta": 2.7719999999999345,
|
||||
"monotonic": true,
|
||||
"phases": {
|
||||
"acceleration": {
|
||||
"start": 8.315999999999804,
|
||||
"end": 144.30947458162336,
|
||||
"samples": 21
|
||||
},
|
||||
"release": {
|
||||
"start": 152.04046450581518,
|
||||
"end": 196.87361420703746,
|
||||
"samples": 8
|
||||
},
|
||||
"coast": {
|
||||
"start": 202.19007442493694,
|
||||
"end": 279.49786736252463,
|
||||
"samples": 21
|
||||
},
|
||||
"reverse-scroll": {
|
||||
"start": 282.60246896668923,
|
||||
"end": 391.58227498378983,
|
||||
"samples": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "4:uDistance",
|
||||
"samples": 74,
|
||||
"start": 8.316,
|
||||
"end": 391.58227498378994,
|
||||
"minimumDelta": 2.7719999999999985,
|
||||
"monotonic": true,
|
||||
"phases": {
|
||||
"acceleration": {
|
||||
"start": 8.316,
|
||||
"end": 144.30947458162322,
|
||||
"samples": 21
|
||||
},
|
||||
"release": {
|
||||
"start": 152.0404645058149,
|
||||
"end": 196.87361420703783,
|
||||
"samples": 8
|
||||
},
|
||||
"coast": {
|
||||
"start": 202.19007442493717,
|
||||
"end": 279.4978673625249,
|
||||
"samples": 21
|
||||
},
|
||||
"reverse-scroll": {
|
||||
"start": 282.6024689666896,
|
||||
"end": 391.58227498378994,
|
||||
"samples": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "5:uDistance",
|
||||
"samples": 74,
|
||||
"start": 8.316,
|
||||
"end": 391.58227498378994,
|
||||
"minimumDelta": 2.7719999999999985,
|
||||
"monotonic": true,
|
||||
"phases": {
|
||||
"acceleration": {
|
||||
"start": 8.316,
|
||||
"end": 144.30947458162322,
|
||||
"samples": 21
|
||||
},
|
||||
"release": {
|
||||
"start": 152.0404645058149,
|
||||
"end": 196.87361420703783,
|
||||
"samples": 8
|
||||
},
|
||||
"coast": {
|
||||
"start": 202.19007442493717,
|
||||
"end": 279.4978673625249,
|
||||
"samples": 21
|
||||
},
|
||||
"reverse-scroll": {
|
||||
"start": 282.6024689666896,
|
||||
"end": 391.58227498378994,
|
||||
"samples": 24
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "6:uDistance",
|
||||
"samples": 74,
|
||||
"start": 8.316,
|
||||
"end": 391.58227498378994,
|
||||
"minimumDelta": 2.7719999999999985,
|
||||
"monotonic": true,
|
||||
"phases": {
|
||||
"acceleration": {
|
||||
"start": 8.316,
|
||||
"end": 144.30947458162322,
|
||||
"samples": 21
|
||||
},
|
||||
"release": {
|
||||
"start": 152.0404645058149,
|
||||
"end": 196.87361420703783,
|
||||
"samples": 8
|
||||
},
|
||||
"coast": {
|
||||
"start": 202.19007442493717,
|
||||
"end": 279.4978673625249,
|
||||
"samples": 21
|
||||
},
|
||||
"reverse-scroll": {
|
||||
"start": 282.6024689666896,
|
||||
"end": 391.58227498378994,
|
||||
"samples": 24
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 690 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 381 KiB |
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"schema": "runtime-budget-trace.v1",
|
||||
"evidenceKind": "authenticated-test-build-production-path",
|
||||
"gatingReferenceResult": false,
|
||||
"profile": "desktop-high",
|
||||
"tier": "high",
|
||||
"observed": {
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
"devicePixelRatio": 2,
|
||||
"rendererPixelRatio": 1.5
|
||||
},
|
||||
"runtimeBudgetEvidence": {
|
||||
"schema": "portfolio-runtime-trace-evidence/v1",
|
||||
"peakRequests": {
|
||||
"imageScene": 1,
|
||||
"video": 0
|
||||
},
|
||||
"allocation": {
|
||||
"tier": "high",
|
||||
"textures": [
|
||||
{
|
||||
"id": "owned-texture-0",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"bytesPerPixel": 4,
|
||||
"layers": 1,
|
||||
"mipmapped": false
|
||||
},
|
||||
{
|
||||
"id": "owned-texture-1",
|
||||
"width": 1024,
|
||||
"height": 288,
|
||||
"bytesPerPixel": 4,
|
||||
"layers": 1,
|
||||
"mipmapped": true
|
||||
}
|
||||
],
|
||||
"geometryBytes": 748476,
|
||||
"renderTargets": [
|
||||
{
|
||||
"id": "default-framebuffer",
|
||||
"width": 2160,
|
||||
"height": 1350,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 4
|
||||
},
|
||||
{
|
||||
"id": "composer-target-1",
|
||||
"width": 2160,
|
||||
"height": 1350,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "composer-target-2",
|
||||
"width": 2160,
|
||||
"height": 1350,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-bright",
|
||||
"width": 1080,
|
||||
"height": 675,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-horizontal-0",
|
||||
"width": 1080,
|
||||
"height": 675,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-horizontal-1",
|
||||
"width": 540,
|
||||
"height": 338,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-horizontal-2",
|
||||
"width": 270,
|
||||
"height": 169,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-horizontal-3",
|
||||
"width": 135,
|
||||
"height": 85,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-horizontal-4",
|
||||
"width": 68,
|
||||
"height": 43,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-vertical-0",
|
||||
"width": 1080,
|
||||
"height": 675,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-vertical-1",
|
||||
"width": 540,
|
||||
"height": 338,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-vertical-2",
|
||||
"width": 270,
|
||||
"height": 169,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-vertical-3",
|
||||
"width": 135,
|
||||
"height": 85,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
},
|
||||
{
|
||||
"id": "bloom-vertical-4",
|
||||
"width": 68,
|
||||
"height": 43,
|
||||
"bytesPerPixel": 4,
|
||||
"attachments": 2,
|
||||
"samples": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"runtimeBudgetSummary": {
|
||||
"tier": "high",
|
||||
"textureBytes": 1589248,
|
||||
"geometryBytes": 748476,
|
||||
"renderTargetBytes": 161344784,
|
||||
"runtimeAllowanceBytes": 33554432,
|
||||
"totalBytes": 197236940
|
||||
},
|
||||
"violations": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 382 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema": "nightdrive-sign-lifecycle-trace/v1",
|
||||
"renderedLabels": [
|
||||
"Profile"
|
||||
],
|
||||
"distinctLabels": [
|
||||
"Profile"
|
||||
],
|
||||
"staleLabelMutated": false
|
||||
}
|
||||
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 381 KiB |
@@ -1,6 +1,6 @@
|
||||
# Portfolio visual QA
|
||||
|
||||
Status date: 2026-09-02
|
||||
Status date: 2026-09-03
|
||||
|
||||
## Automated capture contract
|
||||
|
||||
@@ -81,14 +81,42 @@ The refreshed authenticated 1440×900 High-tier allocation trace passed at devic
|
||||
|
||||
The final ownership split remains deliberate. Blender should continue to own only the finite car because silhouette, bevels, clean normals, wheel forms, material groups, and LOD/export discipline justify authored geometry. Terrain, road, sky, horizon banks, distant silhouettes, roadside anchors, sun, stars, streaks, signs, camera, palette, and scroll response should stay in Three.js because they depend on infinite wrapping, dynamic text, responsive framing, reversible route state, and performance-tier control. The added depth layers strengthen that conclusion: their deterministic instancing, chapter coupling, and FOV-safe screen/world-space behavior would become less flexible as baked GLB scenery, while the measured geometry increase is only 2,996 bytes. No additional Blender export is justified.
|
||||
|
||||
## Nightdrive cohesion continuation
|
||||
|
||||
The 2026-09-03 cohesion pass is retained under `docs/reviews/evidence/nightdrive-iteration/cohesion-pass/`. Its `before/` directory preserves the five opening families captured before this pass; `final/` contains the corresponding 390×844, 844×390, 768×1024, 1440×900, and 2560×1440 openings plus dedicated cruise/acceleration horizons, opening/later-chapter suns, acceleration/release/coast particles, three reverse-scroll frames, sign approach/readable/departure/reconciliation states, Static and reduced-motion states, and empty browser-error logs. The supplementary later-chapter sun frame keeps the focal point unobstructed, while the first later-chapter frame proves that it remains subordinate when content crosses it.
|
||||
|
||||
The investigation found four shared causes rather than unrelated tuning defects. First, the road, terrain, and atmospheric layers received a monotonic distance value but multiplied the accumulated phase by the current speed, so easing back toward cruise could make the computed phase decrease. Second, road and terrain geometry ended at the same `z = -410` plane, where opaque black distance fades formed a visible cap; the sun was also in front of the silhouette stack, preventing atmospheric occlusion. Third, sign lifecycle state and sign pose were split: reverse input could cancel a readable approach, target changes could cut short the 1,400ms dwell, departure assumed the dwell pose even when it began during approach, and resize immediately replaced live poses. Fourth, the original sign face used non-normalized shape UVs and then sat behind its replacement housing, which explained the blank/soft face independently of lifecycle behavior.
|
||||
|
||||
The world now has one 794-unit depth contract (`z = 34` through `z = -760`) and one linear 330–705 fog interval shared by native materials and analytic shaders. Road and terrain extend through that envelope, converge through the same fog and horizon light, and no longer use an opaque black endpoint. Asphalt combines low-amplitude broad and fine deterministic variation with longitudinal wear, shoulder breakup, and restrained atmospheric pickup. Lane and shoulder contrast fade into the same horizon instead of disappearing at a separate boundary. The five final viewport captures and both 66° cruise/79° acceleration captures show no finite mesh edge, black cap, floating road, shoulder seam, or exposed camera angle.
|
||||
|
||||
The sun is now a layered shader focal point at `z = -720`, with broad and fine internal variation, a shaded limb, restrained scan bands, a muted halo, and a separate horizon-light bleed. Distant silhouettes at `z = -675` write depth and partially occlude the disc; terrain edges, road pickup, fog, and chapter warmth use the same palette response. Opening and later-chapter evidence shows the richer disc without clipped edges, broad bloom, flare sprites, or content-competing saturation.
|
||||
|
||||
The sign remains runtime-authored Three.js geometry. A clipped beveled housing, backplate, bracket, brace, narrow supports, and planted footing supply finite construction detail, while the 1024×288 mipmapped canvas face keeps chapter text sharp and runtime-owned. Cyan is limited to a small practical corner marker and amber to the route accent. The controller keeps one canonical target, lets concealed signs retarget immediately, preserves visible labels, defers stale departure until the minimum dwell expires, coalesces jumps, and starts departure from the actual current pose. Visible viewport changes are deferred until concealment, avoiding teleportation or revival. Focused browser evidence and `sign-lifecycle-trace.json` record only `Profile` through a forced multi-section jump (`staleLabelMutated: false`). Another Blender asset was not justified: the sign's dynamic text, lifecycle, responsive pose, and quality-tier behavior benefit more from stable runtime geometry than from a baked GLB.
|
||||
|
||||
`travel-distance.ts` is now the only physical travel-phase owner. It advances from elapsed time, maintains an 18-unit-per-second forward cruise floor, adds filtered speed/energy response, caps delayed-frame contribution, and never subtracts document deltas. Road, terrain, streaks, anchors, and other travel uniforms consume that direct phase; scroll direction is still available to non-diegetic anticipation but cannot reverse the world. `forward-motion-trace.json` sampled six live WebGL distance uniforms 74 times while document scroll reversed from 1,800px to 1,280px. Every uniform remained monotonic, advanced through acceleration, release, coast, and reverse scroll, and had a positive minimum observed delta of approximately 2.772 units. Streak visibility is proportional and fades through release; deterministic jitter keeps anchor recycling outside conspicuous regions and avoids synchronized rows. The existing lateral waves were not changed.
|
||||
|
||||
Fresh verification produced these results:
|
||||
|
||||
- Type checking passed.
|
||||
- The complete unit/build gate passed 43 files and 525 tests; the focused world, sign-controller, and drive-controller run passed 64 tests in 3 files.
|
||||
- The complete four-worker browser matrix reported 172 passed checks, 43 deliberate skips, and one existing desktop heading-focus geometry timeout. That exact check passed unchanged in 2.9 seconds with one worker. The newly added sign/navigation cases passed unchanged in both desktop and mobile projects with one worker.
|
||||
- Deployment verification passed 9 tests in 3 files. Its first sandboxed attempt could not bind its local HTTP fixture (`EPERM`); the unchanged test passed when rerun with the required local-network permission.
|
||||
- The authenticated High-tier runtime budget passed with no violations at 1440×900, device DPR 2, and renderer ratio 1.5. It measured 748,476 geometry bytes, 1,589,248 texture bytes, 161,344,784 render-target bytes, a 33,554,432-byte non-render-target allowance, and 197,236,940 total accounted bytes. Peak scene-image concurrency was 1 and video concurrency was 0.
|
||||
- Transfer verification passed with a 222,464-byte Brotli-accounted first-live-scene payload. The environment chunk measured 699,501 raw / 145,875 Brotli bytes, application JavaScript 79,894 transfer bytes, and the unchanged authored car GLB 410,408 raw / 76,589 transfer bytes.
|
||||
- Browser console capture remained empty in both recorded checks. Static, reduced motion, WebGL failure/fallback, accessibility/keyboard, responsive, and flash-safety cases are included among the passing browser checks.
|
||||
- `git diff --check` passed.
|
||||
|
||||
The unchanged 20-second interaction harness still does not yield a valid long trace on this host, so no interaction latency or frame-cadence result is claimed. Its independent mobile-low run loaded content under the prescribed throttle but stayed in `data-scene-state="loading"` beyond the fixed five-second scene-ready window. Its independent desktop-high run again reached the Toolkit CDP action but exceeded the fixed 75-second test timeout, matching the previously documented host behavior. Neither assertion or budget was weakened. The authenticated allocation trace, deterministic live-uniform trace, responsive screenshots, and browser assertions are the measured evidence for this pass; physical-browser, cross-engine, and audience checks remain outstanding below.
|
||||
|
||||
## Direction review checklist
|
||||
|
||||
- [x] Information uses near-black opaque panels rather than neon glass.
|
||||
- [x] Cyan and amber remain accents; content does not use glow or scanlines.
|
||||
- [x] Scene stays decorative and excluded from the accessibility tree.
|
||||
- [x] Clip-space sky and horizon banks cover portrait through ultrawide at both 66° and 79° FOV without a finite-plane seam.
|
||||
- [x] Road, terrain, anchors, and streaks consume one monotonically advancing travel phase and continue through reverse document input and coast.
|
||||
- [x] Road flow, camera, streaks, bloom, and suspension use independent persistent response curves and settle through a visible coast.
|
||||
- [x] Readable stale signs depart cleanly; concealed candidates may retarget; multi-section jumps do not queue stale labels.
|
||||
- [x] Readable stale signs preserve their clock-driven dwell and depart from their live pose; concealed candidates may retarget; multi-section jumps do not queue or mutate stale labels.
|
||||
- [x] The desktop road corridor and alternating panels preserve the chase composition.
|
||||
- [x] Mobile becomes a readable single column and keeps the world subordinate.
|
||||
- [x] The original car is Blender-authored; the infinite road/world remain procedural Three.js and are not AI-generated project artwork.
|
||||
|
||||
@@ -30,6 +30,7 @@ interface VisibleState {
|
||||
targetId: SectionId;
|
||||
latchedAt: number;
|
||||
approachDuration: number;
|
||||
departureRequested: boolean;
|
||||
departStartedAt: number | null;
|
||||
generation: number;
|
||||
}
|
||||
@@ -99,6 +100,7 @@ export function createSignController(): SignController {
|
||||
targetId: input.candidateId,
|
||||
latchedAt: input.now,
|
||||
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
|
||||
departureRequested: false,
|
||||
departStartedAt: null,
|
||||
generation,
|
||||
};
|
||||
@@ -111,6 +113,22 @@ export function createSignController(): SignController {
|
||||
visible.departStartedAt = now;
|
||||
};
|
||||
|
||||
const requestDeparture = (
|
||||
visible: VisibleState,
|
||||
current: SignSnapshot,
|
||||
now: number,
|
||||
): void => {
|
||||
const dwellCompleteAt = visible.latchedAt
|
||||
+ ANTICIPATE_MS
|
||||
+ visible.approachDuration
|
||||
+ DWELL_MS;
|
||||
if (current.phase === 'dwell' && now < dwellCompleteAt) {
|
||||
visible.departureRequested = true;
|
||||
return;
|
||||
}
|
||||
beginDeparture(visible, now);
|
||||
};
|
||||
|
||||
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
|
||||
if (visible.departStartedAt !== null) {
|
||||
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
|
||||
@@ -180,7 +198,7 @@ export function createSignController(): SignController {
|
||||
enterCooldown(input.now);
|
||||
return null;
|
||||
}
|
||||
beginDeparture(state, input.now);
|
||||
requestDeparture(state, current, input.now);
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
@@ -215,26 +233,25 @@ export function createSignController(): SignController {
|
||||
enterCooldown(input.now);
|
||||
return null;
|
||||
}
|
||||
beginDeparture(state, input.now);
|
||||
requestDeparture(state, current, input.now);
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
if (input.direction < 0) {
|
||||
const elapsed = input.now - state.latchedAt;
|
||||
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
|
||||
if (elapsed < ANTICIPATE_MS + state.approachDuration) {
|
||||
if (current.opacity <= CONCEALED_OPACITY) {
|
||||
state = { kind: 'idle' };
|
||||
return null;
|
||||
}
|
||||
if (state.departStartedAt === null && input.now >= dwellStartedAt) {
|
||||
beginDeparture(state, input.now);
|
||||
}
|
||||
if (state.departStartedAt === null) requestDeparture(state, current, input.now);
|
||||
}
|
||||
|
||||
if (state.departStartedAt === null) {
|
||||
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
|
||||
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
|
||||
if (dwellComplete && input.activeSectionId === state.targetId) {
|
||||
if (
|
||||
dwellComplete
|
||||
&& (state.departureRequested || input.activeSectionId === state.targetId)
|
||||
) {
|
||||
beginDeparture(state, input.now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette, MutableColor } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
SILHOUETTE_NEAR_Z,
|
||||
WORLD_DEPTH,
|
||||
WORLD_FAR_Z,
|
||||
WORLD_NEAR_Z,
|
||||
} from '@/scene/world/metrics';
|
||||
|
||||
export interface AtmosphereWorld {
|
||||
group: WorldGroup;
|
||||
@@ -88,7 +95,7 @@ export function createAtmosphere(
|
||||
color: new THREE.Color(night.fog).lerp(new THREE.Color(night.terrainLow), 0.46 + band * 0.12),
|
||||
transparent: true,
|
||||
opacity: 0.2 - band * 0.035,
|
||||
depthWrite: false,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
toneMapped: true,
|
||||
}));
|
||||
@@ -188,8 +195,11 @@ export function createAtmosphere(
|
||||
varying float vTail;
|
||||
void main() {
|
||||
vec3 streak = position;
|
||||
float depthCycle = mod(streak.z + uDistance * 1.55 + 520.0, 526.0) - 520.0;
|
||||
streak.z = depthCycle + endFactor * (1.4 + uAcceleration * 31.0);
|
||||
float depthCycle = mod(
|
||||
streak.z + uDistance - ${WORLD_FAR_Z.toFixed(1)},
|
||||
${WORLD_DEPTH.toFixed(1)}
|
||||
) + ${WORLD_FAR_Z.toFixed(1)};
|
||||
streak.z = depthCycle - endFactor * (1.4 + uAcceleration * 31.0);
|
||||
vTail = endFactor;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0);
|
||||
}
|
||||
@@ -223,10 +233,13 @@ export function createAtmosphere(
|
||||
},
|
||||
update(distance, speed, acceleration, palette) {
|
||||
const safeAcceleration = clamp01(acceleration);
|
||||
const visibility = Math.pow(safeAcceleration, 1.12) * palette.streakEnergy * 0.82;
|
||||
const visibility = safeAcceleration
|
||||
* (0.62 + Math.sqrt(safeAcceleration) * 0.38)
|
||||
* palette.streakEnergy
|
||||
* 0.72;
|
||||
starMaterial.uniforms.uDistance.value = finiteOrZero(distance);
|
||||
starMaterial.uniforms.uVisibility.value = palette.starVisibility;
|
||||
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance) * (0.72 + clamp01(speed) * 0.28);
|
||||
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance);
|
||||
streakMaterial.uniforms.uAcceleration.value = safeAcceleration;
|
||||
streakMaterial.uniforms.uVisibility.value = visibility;
|
||||
skyMaterial.uniforms.uSkyColor.value.copy(palette.sky);
|
||||
@@ -340,7 +353,7 @@ function createSilhouetteGeometry(band: number) {
|
||||
const positions = new Float32Array((segments + 1) * 2 * 3);
|
||||
const indices: number[] = [];
|
||||
const width = 1_900;
|
||||
const centerZ = -455 - band * 54;
|
||||
const centerZ = SILHOUETTE_NEAR_Z - band * 46;
|
||||
const baseY = -38;
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const progress = index / segments;
|
||||
@@ -385,10 +398,13 @@ function createRoadsideAnchors(
|
||||
varying float vDepthFade;
|
||||
void main() {
|
||||
vec4 anchor = instanceMatrix * vec4(position, 1.0);
|
||||
anchor.z = mod(anchor.z + uDistance + 410.0, 438.0) - 410.0;
|
||||
anchor.z = mod(
|
||||
anchor.z + uDistance - ${WORLD_FAR_Z.toFixed(1)},
|
||||
${WORLD_DEPTH.toFixed(1)}
|
||||
) + ${WORLD_FAR_Z.toFixed(1)};
|
||||
vec4 viewPosition = modelViewMatrix * anchor;
|
||||
vDepthFade = (1.0 - smoothstep(250.0, 390.0, -viewPosition.z))
|
||||
* smoothstep(2.0, 20.0, -viewPosition.z);
|
||||
vDepthFade = (1.0 - smoothstep(540.0, ${HORIZON_FOG_END.toFixed(1)}, -viewPosition.z))
|
||||
* smoothstep(12.0, 42.0, -viewPosition.z);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
@@ -413,8 +429,10 @@ function createRoadsideAnchors(
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const side = index % 2 === 0 ? -1 : 1;
|
||||
const lane = Math.floor(index / 2);
|
||||
const z = -8 - (lane / Math.max(1, Math.ceil(count / 2) - 1)) * 420;
|
||||
const x = side * (9.6 + seeded(index, 181) * 1.1);
|
||||
const laneProgress = (lane + seeded(index, 191) * 0.82)
|
||||
/ Math.max(1, Math.ceil(count / 2));
|
||||
const z = WORLD_NEAR_Z - 24 - laneProgress * (WORLD_DEPTH - 52);
|
||||
const x = side * (10.8 + seeded(index, 181) * 1.4);
|
||||
matrix.makeTranslation(x, 0.38, z);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ import { createSign } from '@/scene/world/sign';
|
||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||
import { createPostProcessor } from '@/scene/post-processing';
|
||||
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
} from '@/scene/world/metrics';
|
||||
import { createTravelDistance } from '@/scene/world/travel-distance';
|
||||
import {
|
||||
createMutableChapterPalette,
|
||||
writeChapterPalette,
|
||||
@@ -22,12 +27,16 @@ declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
|
||||
export function createChaseCamera(aspect = 1) {
|
||||
const camera = new THREE.PerspectiveCamera(66, aspect, 0.1, 900);
|
||||
const camera = new THREE.PerspectiveCamera(66, aspect, 0.1, 1_200);
|
||||
camera.position.set(0, 8.6, 22);
|
||||
camera.lookAt(0, 4.8, -128);
|
||||
return camera;
|
||||
}
|
||||
|
||||
export function createWorldFog(color: number) {
|
||||
return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END);
|
||||
}
|
||||
|
||||
export async function createProceduralSceneGeneration(
|
||||
context: SceneBuildContext,
|
||||
): Promise<SceneGeneration> {
|
||||
@@ -65,7 +74,7 @@ export async function createProceduralSceneGeneration(
|
||||
const targetPalette = createMutableChapterPalette('deep-night');
|
||||
const background = currentPalette.sky.clone();
|
||||
scene.background = background;
|
||||
scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.0024);
|
||||
scene.fog = createWorldFog(currentPalette.fog.getHex());
|
||||
const camera = createChaseCamera();
|
||||
|
||||
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
||||
@@ -115,7 +124,7 @@ export async function createProceduralSceneGeneration(
|
||||
};
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let driveDistance = 0;
|
||||
const travelDistance = createTravelDistance();
|
||||
let lastNow: number | null = null;
|
||||
let disposed = false;
|
||||
let renderedFrames = 0;
|
||||
@@ -190,17 +199,17 @@ export async function createProceduralSceneGeneration(
|
||||
const response = dynamics.snapshot();
|
||||
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
|
||||
dampPalette(currentPalette, targetPalette, deltaSeconds);
|
||||
driveDistance += deltaSeconds * (
|
||||
18
|
||||
+ snapshot.driveSpeed * 54
|
||||
+ response.roadEnergy * 48
|
||||
const driveDistance = travelDistance.advance(
|
||||
deltaSeconds,
|
||||
snapshot.driveSpeed,
|
||||
response.roadEnergy,
|
||||
);
|
||||
const visualSpeed = THREE.MathUtils.clamp(
|
||||
0.18 + response.roadEnergy * 0.82,
|
||||
0.18,
|
||||
1,
|
||||
);
|
||||
road.update(driveDistance, visualSpeed, response.roadEnergy);
|
||||
road.update(driveDistance, visualSpeed, response.roadEnergy, currentPalette);
|
||||
terrain.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
@@ -209,7 +218,12 @@ export async function createProceduralSceneGeneration(
|
||||
currentPalette,
|
||||
);
|
||||
const elapsedSeconds = now / 1000;
|
||||
sun.update(currentPalette, snapshot.routeProgress, elapsedSeconds);
|
||||
sun.update(
|
||||
currentPalette,
|
||||
snapshot.routeProgress,
|
||||
elapsedSeconds,
|
||||
response.bloomEnergy,
|
||||
);
|
||||
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
|
||||
sign.update(snapshot.sign);
|
||||
atmosphere.update(
|
||||
@@ -220,7 +234,6 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
background.copy(currentPalette.sky);
|
||||
scene.fog.color.copy(currentPalette.fog);
|
||||
scene.fog.density = 0.0019 + (1 - currentPalette.warmth) * 0.0008;
|
||||
ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26);
|
||||
ambient.groundColor.copy(currentPalette.fog);
|
||||
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
|
||||
|
||||
9
src/scene/world/metrics.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const WORLD_NEAR_Z = 34;
|
||||
export const WORLD_FAR_Z = -760;
|
||||
export const WORLD_DEPTH = WORLD_NEAR_Z - WORLD_FAR_Z;
|
||||
export const WORLD_CENTER_Z = (WORLD_NEAR_Z + WORLD_FAR_Z) * 0.5;
|
||||
|
||||
export const HORIZON_FOG_START = 330;
|
||||
export const HORIZON_FOG_END = 705;
|
||||
export const SILHOUETTE_NEAR_Z = -675;
|
||||
export const SUN_DISTANCE_Z = -720;
|
||||
@@ -1,10 +1,23 @@
|
||||
import * as THREE from 'three';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
WORLD_CENTER_Z,
|
||||
WORLD_DEPTH,
|
||||
} from '@/scene/world/metrics';
|
||||
|
||||
export interface RoadWorld {
|
||||
group: WorldGroup;
|
||||
update(distance: number, speed: number, acceleration: number): void;
|
||||
update(
|
||||
distance: number,
|
||||
speed: number,
|
||||
acceleration: number,
|
||||
palette?: Readonly<MutableChapterPalette>,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface WorldGroup {
|
||||
@@ -13,8 +26,6 @@ interface WorldGroup {
|
||||
}
|
||||
|
||||
const ROAD_WIDTH = 20.4;
|
||||
const ROAD_DEPTH = 438;
|
||||
const ROAD_CENTER_Z = -191;
|
||||
const FLOW_PERIOD = 5_694;
|
||||
|
||||
export function createRoad(
|
||||
@@ -26,36 +37,39 @@ export function createRoad(
|
||||
|
||||
const geometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.PlaneGeometry(ROAD_WIDTH, ROAD_DEPTH, 16, quality.roadSegments),
|
||||
new THREE.PlaneGeometry(ROAD_WIDTH, WORLD_DEPTH, 16, quality.roadSegments),
|
||||
);
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
geometry.translate(0, 0, ROAD_CENTER_Z);
|
||||
geometry.translate(0, 0, WORLD_CENTER_Z);
|
||||
|
||||
const initialPalette = CHAPTER_PALETTES['deep-night'];
|
||||
const uniforms = {
|
||||
uDistance: { value: 0 },
|
||||
uSpeed: { value: 0 },
|
||||
uAcceleration: { value: 0 },
|
||||
uAsphalt: { value: new THREE.Color(0x03070f) },
|
||||
uAsphaltLift: { value: new THREE.Color(0x091422) },
|
||||
uAsphaltLift: { value: new THREE.Color(0x0b1927) },
|
||||
uEdgeColor: { value: new THREE.Color(0x64d9e7) },
|
||||
uLaneColor: { value: new THREE.Color(0xf1a35e) },
|
||||
uFogColor: { value: new THREE.Color(initialPalette.fog) },
|
||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||
};
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
varying vec2 vRoadUv;
|
||||
varying float vRoadX;
|
||||
varying float vDepth;
|
||||
varying float vDistanceFade;
|
||||
varying float vViewDistance;
|
||||
varying float vWorldZ;
|
||||
varying float vNearFade;
|
||||
|
||||
void main() {
|
||||
vRoadUv = uv;
|
||||
vRoadX = position.x;
|
||||
vDepth = smoothstep(0.0, 1.0, uv.y);
|
||||
vWorldZ = position.z;
|
||||
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
float viewDistance = -viewPosition.z;
|
||||
vDistanceFade = smoothstep(4.0, 16.0, viewDistance)
|
||||
* (1.0 - smoothstep(315.0, 430.0, viewDistance));
|
||||
vViewDistance = -viewPosition.z;
|
||||
vNearFade = smoothstep(4.0, 18.0, vViewDistance);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
@@ -67,39 +81,73 @@ export function createRoad(
|
||||
uniform vec3 uAsphaltLift;
|
||||
uniform vec3 uEdgeColor;
|
||||
uniform vec3 uLaneColor;
|
||||
uniform vec3 uFogColor;
|
||||
uniform vec3 uHazeColor;
|
||||
varying vec2 vRoadUv;
|
||||
varying float vRoadX;
|
||||
varying float vDepth;
|
||||
varying float vDistanceFade;
|
||||
varying float vViewDistance;
|
||||
varying float vWorldZ;
|
||||
varying float vNearFade;
|
||||
|
||||
float hash21(vec2 point) {
|
||||
point = fract(point * vec2(123.34, 456.21));
|
||||
point += dot(point, point + 45.32);
|
||||
return fract(point.x * point.y);
|
||||
}
|
||||
|
||||
float valueNoise(vec2 point) {
|
||||
vec2 cell = floor(point);
|
||||
vec2 local = fract(point);
|
||||
local = local * local * (3.0 - 2.0 * local);
|
||||
return mix(
|
||||
mix(hash21(cell), hash21(cell + vec2(1.0, 0.0)), local.x),
|
||||
mix(hash21(cell + vec2(0.0, 1.0)), hash21(cell + vec2(1.0)), local.x),
|
||||
local.y
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float roadX = abs(vRoadX);
|
||||
float coreX = roadX / 8.2;
|
||||
float centerValley = 1.0 - smoothstep(0.0, 0.86, coreX);
|
||||
float outerEdge = smoothstep(7.76, 7.93, roadX)
|
||||
* (1.0 - smoothstep(8.06, 8.19, roadX));
|
||||
float shoulder = smoothstep(7.2, 7.82, roadX)
|
||||
* (1.0 - smoothstep(8.18, 10.0, roadX));
|
||||
float shoulderFalloff = 1.0 - smoothstep(8.1, 10.2, roadX);
|
||||
float laneWidth = 1.0 - smoothstep(0.075, 0.18, roadX);
|
||||
float laneCycle = fract(vRoadUv.y * 33.7 + mod(uDistance, ${FLOW_PERIOD.toFixed(1)}) * 0.078);
|
||||
float laneDash = smoothstep(0.12, 0.2, laneCycle)
|
||||
* (1.0 - smoothstep(0.62, 0.72, laneCycle));
|
||||
float centerLane = laneWidth * laneDash * smoothstep(0.035, 0.18, vRoadUv.y);
|
||||
float edgeLuminance = outerEdge * (1.2 + uAcceleration * 0.68);
|
||||
float coarseAsphalt = sin(vRoadUv.y * 173.0 + roadX * 4.7 + uDistance * 0.08)
|
||||
* sin(vRoadUv.y * 47.0 - roadX * 8.1) * 0.006;
|
||||
float fineGrain = sin((vRoadUv.y * 530.0 + roadX * 5.0) + uDistance * 0.1) * 0.003;
|
||||
float horizonReflection = smoothstep(0.42, 0.94, vRoadUv.y)
|
||||
* exp(-roadX * 0.34) * (0.028 + uAcceleration * 0.018);
|
||||
float travelZ = -vWorldZ + mod(uDistance, ${FLOW_PERIOD.toFixed(1)});
|
||||
float asphaltMask = 1.0 - smoothstep(7.78, 8.2, roadX);
|
||||
float shoulderMask = smoothstep(7.72, 8.08, roadX);
|
||||
float shoulderSeam = smoothstep(7.22, 7.34, roadX)
|
||||
* (1.0 - smoothstep(7.46, 7.62, roadX));
|
||||
float outerEdge = smoothstep(7.86, 7.98, roadX)
|
||||
* (1.0 - smoothstep(8.06, 8.18, roadX));
|
||||
float laneWidth = 1.0 - smoothstep(0.07, 0.15, roadX);
|
||||
float laneCycle = fract(travelZ / 18.0);
|
||||
float laneDash = smoothstep(0.05, 0.1, laneCycle)
|
||||
* (1.0 - smoothstep(0.48, 0.56, laneCycle));
|
||||
float laneDistanceFade = 1.0 - smoothstep(430.0, 650.0, vViewDistance);
|
||||
float centerLane = laneWidth * laneDash * laneDistanceFade;
|
||||
|
||||
vec3 color = mix(uAsphaltLift, uAsphalt, centerValley * 0.84);
|
||||
color += uEdgeColor * (edgeLuminance * 1.35 + shoulder * 0.18);
|
||||
color += uLaneColor * centerLane * (1.15 + uSpeed * 0.28);
|
||||
color += uLaneColor * horizonReflection;
|
||||
color += coarseAsphalt + fineGrain;
|
||||
color = mix(uAsphalt * 0.68, color, shoulderFalloff);
|
||||
color *= vDistanceFade * mix(0.58, 1.0, smoothstep(0.0, 0.2, vRoadUv.y));
|
||||
float broadVariation = valueNoise(vec2(vRoadX * 0.12 + 8.0, travelZ * 0.018));
|
||||
float aggregate = valueNoise(vec2(vRoadX * 0.48 - 3.0, travelZ * 0.085));
|
||||
float grain = valueNoise(vec2(vRoadX * 2.1 + 19.0, travelZ * 0.31));
|
||||
float tireWear = exp(-pow(abs(roadX - 2.5) * 0.82, 2.0));
|
||||
float longitudinalWear = tireWear * (0.012 + aggregate * 0.014);
|
||||
float shoulderBreakup = mix(0.72, 1.02, aggregate * 0.68 + grain * 0.32);
|
||||
|
||||
vec3 asphalt = mix(uAsphalt, uAsphaltLift, 0.3 + broadVariation * 0.38);
|
||||
asphalt *= 0.9 + aggregate * 0.09 + grain * 0.025;
|
||||
asphalt += uHazeColor * longitudinalWear;
|
||||
vec3 shoulder = mix(uAsphalt * 0.54, uAsphaltLift * 0.7, broadVariation);
|
||||
shoulder *= shoulderBreakup;
|
||||
vec3 color = mix(shoulder, asphalt, asphaltMask);
|
||||
color += uEdgeColor * outerEdge * (0.78 + uAcceleration * 0.34);
|
||||
color += uEdgeColor * shoulderMask * grain * 0.026;
|
||||
color += uHazeColor * shoulderSeam * (0.035 + aggregate * 0.025);
|
||||
color += uLaneColor * centerLane * (0.86 + uSpeed * 0.18);
|
||||
|
||||
float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)
|
||||
* (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance))
|
||||
* exp(-roadX * 0.28);
|
||||
color += mix(uHazeColor, uLaneColor, 0.24) * horizonPickup * (0.08 + uAcceleration * 0.025);
|
||||
float fog = smoothstep(${HORIZON_FOG_START.toFixed(1)}, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance);
|
||||
vec3 atmosphericRoad = mix(uFogColor, uHazeColor, 0.16) * 0.48;
|
||||
color = mix(color, atmosphericRoad, fog);
|
||||
color *= vNearFade;
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
@@ -115,10 +163,14 @@ export function createRoad(
|
||||
|
||||
return {
|
||||
group,
|
||||
update(distance, speed, acceleration) {
|
||||
update(distance, speed, acceleration, palette) {
|
||||
uniforms.uDistance.value = ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
|
||||
uniforms.uSpeed.value = clamp01(speed);
|
||||
uniforms.uAcceleration.value = clamp01(acceleration);
|
||||
if (palette) {
|
||||
uniforms.uFogColor.value.copy(palette.fog);
|
||||
uniforms.uHazeColor.value.copy(palette.haze);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,23 @@ const routeLabels: Readonly<Record<SectionId, string>> = {
|
||||
contact: 'Contact',
|
||||
};
|
||||
|
||||
interface SignPose {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
const NARROW_SIGN_POSES = {
|
||||
horizon: { x: 5.2, y: 6.8, z: -142, scale: 0.15 },
|
||||
dwell: { x: 5.8, y: 4.86, z: -20, scale: 0.82 },
|
||||
} as const;
|
||||
|
||||
const WIDE_SIGN_POSES = {
|
||||
horizon: { x: 9.1, y: 7.4, z: -160, scale: 0.18 },
|
||||
dwell: { x: 10.7, y: 6.64, z: -14, scale: 1.12 },
|
||||
} as const;
|
||||
|
||||
export interface SignWorld {
|
||||
group: SignGroup;
|
||||
setViewportAspect(aspect: number): void;
|
||||
@@ -22,9 +39,16 @@ export interface SignWorld {
|
||||
|
||||
interface SignGroup {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
set(x: number, y: number, z: number): void;
|
||||
clone(): SignGroup['position'];
|
||||
distanceTo(other: SignGroup['position']): number;
|
||||
};
|
||||
rotation: { y: number };
|
||||
scale: { setScalar(scale: number): void };
|
||||
scale: { x: number; setScalar(scale: number): void };
|
||||
add(...objects: unknown[]): void;
|
||||
getObjectByName(name: string): unknown;
|
||||
}
|
||||
@@ -38,89 +62,159 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
group.name = 'roadside-sign';
|
||||
group.visible = false;
|
||||
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x07101a,
|
||||
roughness: 0.7,
|
||||
metalness: 0.42,
|
||||
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x071019,
|
||||
roughness: 0.58,
|
||||
metalness: 0.66,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const frameMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x2d6f79,
|
||||
emissive: 0x43cfdd,
|
||||
emissiveIntensity: 0.68,
|
||||
roughness: 0.38,
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x111d27,
|
||||
roughness: 0.78,
|
||||
metalness: 0.34,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x142631,
|
||||
roughness: 0.52,
|
||||
metalness: 0.72,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x3a8892,
|
||||
emissive: 0x53d2dc,
|
||||
emissiveIntensity: 1.15,
|
||||
roughness: 0.34,
|
||||
metalness: 0.58,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const postMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x172b35,
|
||||
roughness: 0.64,
|
||||
metalness: 0.62,
|
||||
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x8b522d,
|
||||
emissive: 0xe49a58,
|
||||
emissiveIntensity: 0.72,
|
||||
roughness: 0.42,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const housing = new THREE.Mesh(
|
||||
resources.own('geometry', createSignBody(7.5, 2.3, 0.34, 0.2)),
|
||||
housingMaterial,
|
||||
);
|
||||
housing.name = 'sign-housing';
|
||||
housing.position.z = -0.05;
|
||||
group.add(housing);
|
||||
|
||||
const backplate = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(8.2, 2.35, 0.24)),
|
||||
resources.own('geometry', createSignBody(7.2, 2.04, 0.18, 0.16)),
|
||||
backplateMaterial,
|
||||
);
|
||||
backplate.name = 'sign-backplate';
|
||||
backplate.position.z = 0.17;
|
||||
group.add(backplate);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1_024;
|
||||
canvas.height = 256;
|
||||
canvas.height = 288;
|
||||
const context = canvas.getContext('2d');
|
||||
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.anisotropy = 4;
|
||||
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
side: THREE.FrontSide,
|
||||
map: texture,
|
||||
toneMapped: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
toneMapped: false,
|
||||
}));
|
||||
const face = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(7.72, 1.87)),
|
||||
resources.own('geometry', new THREE.PlaneGeometry(6.92, 1.76)),
|
||||
faceMaterial,
|
||||
);
|
||||
face.name = 'sign-face';
|
||||
face.position.z = 0.132;
|
||||
face.position.z = 0.545;
|
||||
face.userData.textureUpdateCount = 0;
|
||||
group.add(face);
|
||||
|
||||
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 edgeLight = new THREE.Group();
|
||||
edgeLight.name = 'sign-edge-light';
|
||||
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.055, 1.42, 0.055));
|
||||
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.16, 0.045, 0.055));
|
||||
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||
leftEdge.position.set(-3.36, 0, 0.585);
|
||||
edgeLight.add(leftEdge);
|
||||
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
||||
upperEdge.position.set(-2.81, 0.88, 0.585);
|
||||
edgeLight.add(upperEdge);
|
||||
group.add(edgeLight);
|
||||
|
||||
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);
|
||||
const routeAccent = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(1.05, 0.055, 0.065)),
|
||||
routeMaterial,
|
||||
);
|
||||
routeAccent.name = 'sign-route-accent';
|
||||
routeAccent.position.set(2.77, -0.88, 0.585);
|
||||
group.add(routeAccent);
|
||||
|
||||
const post = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.14, 4.85, 8)),
|
||||
supportMaterial,
|
||||
);
|
||||
post.name = 'sign-post';
|
||||
post.position.set(2.35, -3.32, -0.14);
|
||||
group.add(post);
|
||||
}
|
||||
|
||||
const fadeMaterials = [backplateMaterial, frameMaterial, postMaterial, faceMaterial];
|
||||
const bracket = new THREE.Group();
|
||||
bracket.name = 'sign-bracket';
|
||||
const arm = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(2.5, 0.11, 0.15)),
|
||||
supportMaterial,
|
||||
);
|
||||
arm.position.set(1.18, -1.08, -0.13);
|
||||
bracket.add(arm);
|
||||
const brace = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(0.1, 1.25, 0.12)),
|
||||
supportMaterial,
|
||||
);
|
||||
brace.position.set(1.72, -1.56, -0.13);
|
||||
brace.rotation.z = -0.72;
|
||||
bracket.add(brace);
|
||||
group.add(bracket);
|
||||
|
||||
const footing = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.CylinderGeometry(0.28, 0.34, 0.34, 8)),
|
||||
supportMaterial,
|
||||
);
|
||||
footing.name = 'sign-footing';
|
||||
footing.position.set(2.35, -5.76, -0.14);
|
||||
group.add(footing);
|
||||
|
||||
const fadeMaterials = [
|
||||
[housingMaterial, 0.97],
|
||||
[backplateMaterial, 1],
|
||||
[supportMaterial, 0.94],
|
||||
[edgeMaterial, 0.76],
|
||||
[routeMaterial, 0.82],
|
||||
[faceMaterial, 1],
|
||||
] as const;
|
||||
const departureOrigin = new THREE.Vector3();
|
||||
let departureScale = 1;
|
||||
let target: SectionId | null = null;
|
||||
let narrowViewport = false;
|
||||
let pendingNarrowViewport = false;
|
||||
let activeNarrowViewport = false;
|
||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||
|
||||
const updateTexture = (id: SectionId): void => {
|
||||
if (target === id) return;
|
||||
@@ -131,36 +225,43 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
};
|
||||
|
||||
const applyOpacity = (opacity: number): void => {
|
||||
for (const material of fadeMaterials) material.opacity = opacity;
|
||||
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
|
||||
};
|
||||
|
||||
const applyPose = (pose: SignPose): void => {
|
||||
group.position.set(pose.x, pose.y, pose.z);
|
||||
group.scale.setScalar(pose.scale);
|
||||
};
|
||||
|
||||
return {
|
||||
group,
|
||||
setViewportAspect(aspect) {
|
||||
narrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
||||
pendingNarrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
||||
if (!group.visible) activeNarrowViewport = pendingNarrowViewport;
|
||||
},
|
||||
update(sign) {
|
||||
if (!sign) {
|
||||
group.visible = false;
|
||||
applyOpacity(0);
|
||||
previousPhase = null;
|
||||
activeNarrowViewport = pendingNarrowViewport;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target !== null && target !== sign.targetId && group.visible) return;
|
||||
if (target !== sign.targetId) {
|
||||
activeNarrowViewport = pendingNarrowViewport;
|
||||
updateTexture(sign.targetId);
|
||||
}
|
||||
|
||||
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 };
|
||||
const { horizon, dwell } = activeNarrowViewport
|
||||
? NARROW_SIGN_POSES
|
||||
: WIDE_SIGN_POSES;
|
||||
|
||||
if (sign.phase === 'anticipate') {
|
||||
group.position.set(horizon.x, horizon.y, horizon.z);
|
||||
group.scale.setScalar(horizon.scale);
|
||||
applyPose(horizon);
|
||||
} else if (sign.phase === 'approach') {
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
|
||||
@@ -169,21 +270,59 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
);
|
||||
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);
|
||||
applyPose(dwell);
|
||||
} 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));
|
||||
if (previousPhase !== 'depart') {
|
||||
if (previousPhase === null) applyPose(dwell);
|
||||
departureOrigin.copy(group.position);
|
||||
departureScale = group.scale.x;
|
||||
}
|
||||
group.rotation.y = narrowViewport ? -0.08 : -0.16;
|
||||
group.position.set(
|
||||
departureOrigin.x + progress * (activeNarrowViewport ? 2.2 : 4.4),
|
||||
departureOrigin.y + progress * 0.62,
|
||||
THREE.MathUtils.lerp(departureOrigin.z, 28, progress),
|
||||
);
|
||||
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 1.06, progress));
|
||||
}
|
||||
|
||||
group.rotation.y = activeNarrowViewport ? -0.14 : -0.23;
|
||||
applyOpacity(opacity);
|
||||
group.visible = opacity > 0;
|
||||
previousPhase = sign.phase;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSignBody(width: number, height: number, depth: number, cut: number) {
|
||||
const geometry = new THREE.ExtrudeGeometry(createClippedShape(width, height, cut), {
|
||||
depth,
|
||||
bevelEnabled: true,
|
||||
bevelSegments: 1,
|
||||
bevelSize: 0.045,
|
||||
bevelThickness: 0.045,
|
||||
curveSegments: 1,
|
||||
});
|
||||
geometry.translate(0, 0, -depth * 0.5);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function createClippedShape(width: number, height: number, cut: number) {
|
||||
const halfWidth = width * 0.5;
|
||||
const halfHeight = height * 0.5;
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-halfWidth + cut, -halfHeight);
|
||||
shape.lineTo(halfWidth - cut, -halfHeight);
|
||||
shape.lineTo(halfWidth, -halfHeight + cut);
|
||||
shape.lineTo(halfWidth, halfHeight - cut);
|
||||
shape.lineTo(halfWidth - cut, halfHeight);
|
||||
shape.lineTo(-halfWidth + cut, halfHeight);
|
||||
shape.lineTo(-halfWidth, halfHeight - cut);
|
||||
shape.lineTo(-halfWidth, -halfHeight + cut);
|
||||
shape.closePath();
|
||||
return shape;
|
||||
}
|
||||
|
||||
function drawLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
label: string,
|
||||
@@ -191,22 +330,28 @@ function drawLabel(
|
||||
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.fillStyle = '#07111a';
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.fillStyle = '#55d4df';
|
||||
context.fillRect(42, 36, 8, height - 72);
|
||||
context.fillStyle = '#91aab3';
|
||||
context.font = '500 23px "Geist Mono Variable", ui-monospace, monospace';
|
||||
context.textAlign = 'left';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText(label, width / 2, height / 2 + 2, width - 88);
|
||||
context.fillText('NEXT / ROUTE', 82, 72);
|
||||
context.fillStyle = '#eef7f8';
|
||||
context.font = '650 74px "Geist Mono Variable", ui-monospace, monospace';
|
||||
context.fillText(label, 82, 170, width - 210);
|
||||
context.strokeStyle = '#d99758';
|
||||
context.lineWidth = 7;
|
||||
context.lineCap = 'square';
|
||||
context.beginPath();
|
||||
context.moveTo(width - 116, 156);
|
||||
context.lineTo(width - 74, 156);
|
||||
context.lineTo(width - 92, 138);
|
||||
context.moveTo(width - 74, 156);
|
||||
context.lineTo(width - 92, 174);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
|
||||
@@ -2,10 +2,16 @@ import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import { SUN_DISTANCE_Z } from '@/scene/world/metrics';
|
||||
|
||||
export interface SunWorld {
|
||||
group: WorldGroup;
|
||||
update(palette: Readonly<MutableChapterPalette>, routeProgress: number, elapsedSeconds: number): void;
|
||||
update(
|
||||
palette: Readonly<MutableChapterPalette>,
|
||||
routeProgress: number,
|
||||
elapsedSeconds: number,
|
||||
driveEnergy?: number,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface WorldGroup {
|
||||
@@ -20,12 +26,14 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
const sunColor = new THREE.Color(night.sun);
|
||||
const uniforms = {
|
||||
uColor: { value: sunColor },
|
||||
uHazeColor: { value: new THREE.Color(night.haze) },
|
||||
uScanBands: { value: 11 },
|
||||
uWarmth: { value: night.warmth },
|
||||
uRouteProgress: { value: 0 },
|
||||
uTime: { value: 0 },
|
||||
uDriveEnergy: { value: 0 },
|
||||
};
|
||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(44, 128));
|
||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(74, 128));
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
@@ -38,24 +46,42 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uHazeColor;
|
||||
uniform float uScanBands;
|
||||
uniform float uWarmth;
|
||||
uniform float uRouteProgress;
|
||||
uniform float uTime;
|
||||
uniform float uDriveEnergy;
|
||||
varying vec2 vSunUv;
|
||||
|
||||
void main() {
|
||||
float radius = distance(vSunUv, vec2(0.5));
|
||||
float softEdge = 1.0 - smoothstep(0.475, 0.5, radius);
|
||||
float lowerDisc = 1.0 - smoothstep(0.46, 0.84, vSunUv.y);
|
||||
float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.12) * 0.012);
|
||||
float scanBand = smoothstep(0.12, 0.2, scanPhase)
|
||||
* (1.0 - smoothstep(0.58, 0.72, scanPhase));
|
||||
float bandCut = mix(1.0, 0.18 + scanBand * 0.82, lowerDisc * 0.9);
|
||||
float horizonFade = smoothstep(0.01, 0.11, vSunUv.y);
|
||||
float verticalHeat = mix(0.78, 1.3, smoothstep(0.1, 0.88, vSunUv.y));
|
||||
float luminance = verticalHeat * (1.28 + uWarmth * 0.24 + uRouteProgress * 0.08);
|
||||
gl_FragColor = vec4(uColor * luminance, softEdge * bandCut * horizonFade);
|
||||
vec2 centered = vSunUv - 0.5;
|
||||
float radius = length(centered);
|
||||
float softEdge = 1.0 - smoothstep(0.465, 0.5, radius);
|
||||
float limb = smoothstep(0.31, 0.485, radius);
|
||||
float broadField = 0.5 + 0.5 * sin(
|
||||
vSunUv.x * 9.0
|
||||
+ sin(vSunUv.y * 11.0 + uTime * 0.028) * 1.35
|
||||
);
|
||||
float fineField = 0.5 + 0.5 * sin(
|
||||
vSunUv.x * 23.0 - vSunUv.y * 16.0 + sin(uTime * 0.021) * 0.7
|
||||
);
|
||||
float internalField = broadField * 0.7 + fineField * 0.3;
|
||||
float verticalHeat = mix(0.68, 1.3, smoothstep(0.06, 0.92, vSunUv.y));
|
||||
float lowerDisc = 1.0 - smoothstep(0.28, 0.67, vSunUv.y);
|
||||
float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.08) * 0.008);
|
||||
float scanBand = smoothstep(0.09, 0.18, scanPhase)
|
||||
* (1.0 - smoothstep(0.72, 0.82, scanPhase));
|
||||
float bandCut = mix(1.0, 0.54 + scanBand * 0.46, lowerDisc * 0.78);
|
||||
float horizonFade = smoothstep(0.015, 0.13, vSunUv.y);
|
||||
float radialCore = 1.0 - smoothstep(0.02, 0.48, radius);
|
||||
float luminance = verticalHeat
|
||||
* (1.08 + uWarmth * 0.16 + uRouteProgress * 0.045 + uDriveEnergy * 0.045);
|
||||
vec3 warmCore = mix(uColor, vec3(1.0, 0.61, 0.43), uWarmth * 0.18 + uRouteProgress * 0.035);
|
||||
vec3 core = mix(uColor * 0.78, warmCore * 1.12, radialCore * 0.58);
|
||||
core = mix(core, core * mix(0.82, 1.13, internalField), 0.54);
|
||||
core = mix(core, uHazeColor * 0.72, limb * 0.22 + lowerDisc * 0.055);
|
||||
gl_FragColor = vec4(core * luminance, softEdge * bandCut * horizonFade);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
@@ -66,15 +92,51 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
}));
|
||||
const disc = new THREE.Mesh(geometry, material);
|
||||
disc.name = 'horizon-sun';
|
||||
disc.position.set(0, 28, -410);
|
||||
disc.position.set(0, 48, SUN_DISTANCE_Z);
|
||||
group.add(disc);
|
||||
|
||||
const limbMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: sunColor.clone() },
|
||||
uEnergy: { value: 0 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uEnergy;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float radius = distance(vUv, vec2(0.5));
|
||||
float ring = smoothstep(0.405, 0.475, radius)
|
||||
* (1.0 - smoothstep(0.475, 0.515, radius));
|
||||
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025));
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const limb = new THREE.Mesh(geometry, limbMaterial);
|
||||
limb.name = 'sun-limb-glow';
|
||||
limb.position.copy(disc.position);
|
||||
limb.position.z -= 0.3;
|
||||
limb.scale.setScalar(1.075);
|
||||
group.add(limb);
|
||||
|
||||
const haloTexture = resources.own('texture', createHaloTexture());
|
||||
const haloMaterial = resources.own('material', new THREE.SpriteMaterial({
|
||||
map: haloTexture,
|
||||
color: sunColor.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.24,
|
||||
opacity: 0.13,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
@@ -83,22 +145,48 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
const halo = new THREE.Sprite(haloMaterial);
|
||||
halo.name = 'horizon-halo';
|
||||
halo.position.copy(disc.position);
|
||||
halo.position.z -= 0.5;
|
||||
halo.scale.set(220, 138, 1);
|
||||
halo.position.z -= 1.2;
|
||||
halo.scale.set(330, 232, 1);
|
||||
group.add(halo);
|
||||
|
||||
const bleedMaterial = resources.own('material', new THREE.SpriteMaterial({
|
||||
map: haloTexture,
|
||||
color: sunColor.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.075,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const bleed = new THREE.Sprite(bleedMaterial);
|
||||
bleed.name = 'horizon-light-bleed';
|
||||
bleed.position.set(0, 7, SUN_DISTANCE_Z - 2.2);
|
||||
bleed.scale.set(560, 118, 1);
|
||||
group.add(bleed);
|
||||
|
||||
return {
|
||||
group,
|
||||
update(palette, routeProgress, elapsedSeconds) {
|
||||
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0) {
|
||||
const progress = clamp01(routeProgress);
|
||||
const energy = clamp01(driveEnergy);
|
||||
sunColor.copy(palette.sun);
|
||||
haloMaterial.color.copy(palette.sun);
|
||||
uniforms.uHazeColor.value.copy(palette.haze);
|
||||
haloMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22);
|
||||
bleedMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22);
|
||||
limbMaterial.uniforms.uColor.value.copy(palette.sun);
|
||||
uniforms.uWarmth.value = clamp01(palette.warmth);
|
||||
uniforms.uRouteProgress.value = progress;
|
||||
uniforms.uTime.value = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
|
||||
haloMaterial.opacity = 0.12 + palette.warmth * 0.08;
|
||||
disc.position.y = 28 + palette.warmth * 7;
|
||||
uniforms.uDriveEnergy.value = energy;
|
||||
limbMaterial.uniforms.uEnergy.value = energy;
|
||||
haloMaterial.opacity = 0.105 + palette.warmth * 0.05 + energy * 0.015;
|
||||
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
|
||||
halo.scale.set(330 + energy * 18, 232 + energy * 10, 1);
|
||||
disc.position.y = 48 + palette.warmth * 9;
|
||||
limb.position.y = disc.position.y;
|
||||
halo.position.y = disc.position.y;
|
||||
bleed.position.y = 7 + palette.warmth * 3;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import type { EnvironmentChapter } from '@/journey/types';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import { CHAPTER_PALETTES, type MutableChapterPalette } from '@/scene/world/palette';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
WORLD_DEPTH,
|
||||
WORLD_NEAR_Z,
|
||||
} from '@/scene/world/metrics';
|
||||
|
||||
export interface TerrainWorld {
|
||||
group: WorldGroup;
|
||||
@@ -21,9 +27,7 @@ interface WorldGroup {
|
||||
}
|
||||
|
||||
const TERRAIN_WIDTH = 360;
|
||||
const TERRAIN_DEPTH = 438;
|
||||
const NEAR_Z = 28;
|
||||
const ROAD_CLEAR_HALF_WIDTH = 9.4;
|
||||
const ROAD_CLEAR_HALF_WIDTH = 10.3;
|
||||
const RIDGE_START = 46;
|
||||
const NOISE_PERIOD = 5_694;
|
||||
|
||||
@@ -39,20 +43,24 @@ export function createTerrain(
|
||||
const highColor = new THREE.Color(initialPalette.terrainHigh);
|
||||
const uniforms = {
|
||||
uDistance: { value: 0 },
|
||||
uFlowDistance: { value: 0 },
|
||||
uSpeed: { value: 0 },
|
||||
uRouteProgress: { value: 0 },
|
||||
uTerrainEnergy: { value: initialPalette.terrainEnergy },
|
||||
uOctaves: { value: quality.terrainOctaves },
|
||||
uRelief: { value: 34 },
|
||||
uFar: { value: 430 },
|
||||
uFar: { value: HORIZON_FOG_END },
|
||||
uLowColor: { value: lowColor },
|
||||
uHighColor: { value: highColor },
|
||||
uFogColor: { value: new THREE.Color(initialPalette.fog) },
|
||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||
};
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
attribute float roadMask;
|
||||
uniform float uDistance;
|
||||
uniform float uFlowDistance;
|
||||
uniform float uSpeed;
|
||||
uniform float uRouteProgress;
|
||||
uniform float uTerrainEnergy;
|
||||
@@ -104,14 +112,16 @@ export function createTerrain(
|
||||
vec4 viewPosition = modelViewMatrix * vec4(displaced, 1.0);
|
||||
float viewDistance = -viewPosition.z;
|
||||
vDepthFade = smoothstep(4.0, 18.0, viewDistance)
|
||||
* (1.0 - smoothstep(uFar * 0.32, uFar * 0.88, viewDistance));
|
||||
vFlow = fract((-position.z + uDistance * (0.9 + uSpeed * 0.25)) / 11.0);
|
||||
* (1.0 - smoothstep(${(HORIZON_FOG_START + 150).toFixed(1)}, ${HORIZON_FOG_END.toFixed(1)}, viewDistance));
|
||||
vFlow = fract((-position.z + uFlowDistance) / 11.0);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uLowColor;
|
||||
uniform vec3 uHighColor;
|
||||
uniform vec3 uFogColor;
|
||||
uniform vec3 uHazeColor;
|
||||
uniform float uRouteProgress;
|
||||
varying float vHeight;
|
||||
varying float vRoadMask;
|
||||
@@ -121,6 +131,7 @@ export function createTerrain(
|
||||
void main() {
|
||||
float ridgeMix = smoothstep(0.16, 0.78, vHeight);
|
||||
vec3 color = mix(uLowColor, uHighColor, ridgeMix);
|
||||
color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade);
|
||||
float pulse = 0.72 + 0.28 * smoothstep(0.16, 0.55, vFlow);
|
||||
float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
|
||||
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.32, 0.82, ridgeMix);
|
||||
@@ -144,12 +155,15 @@ export function createTerrain(
|
||||
update(distance, speed, chapter, routeProgress, palette) {
|
||||
const chapterPalette = CHAPTER_PALETTES[chapter];
|
||||
uniforms.uDistance.value = ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
|
||||
uniforms.uFlowDistance.value = uniforms.uDistance.value;
|
||||
uniforms.uSpeed.value = clamp01(speed);
|
||||
uniforms.uRouteProgress.value = clamp01(routeProgress);
|
||||
uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
|
||||
if (palette) {
|
||||
lowColor.copy(palette.terrainLow);
|
||||
highColor.copy(palette.terrainHigh);
|
||||
uniforms.uFogColor.value.copy(palette.fog);
|
||||
uniforms.uHazeColor.value.copy(palette.haze);
|
||||
} else {
|
||||
lowColor.setHex(chapterPalette.terrainLow);
|
||||
highColor.setHex(chapterPalette.terrainHigh);
|
||||
@@ -160,7 +174,7 @@ export function createTerrain(
|
||||
|
||||
function createIndexedTerrainGeometry(segments: number) {
|
||||
const zSegments = Math.max(16, Math.floor(segments));
|
||||
const approximateCell = TERRAIN_DEPTH / zSegments;
|
||||
const approximateCell = WORLD_DEPTH / zSegments;
|
||||
const xSegments = Math.max(48, Math.floor((TERRAIN_WIDTH / approximateCell) / 2) * 2);
|
||||
const columnCount = xSegments + 1;
|
||||
const rowCount = zSegments + 1;
|
||||
@@ -170,7 +184,7 @@ function createIndexedTerrainGeometry(segments: number) {
|
||||
let maskOffset = 0;
|
||||
|
||||
for (let row = 0; row < rowCount; row += 1) {
|
||||
const z = NEAR_Z - (row / zSegments) * TERRAIN_DEPTH;
|
||||
const z = WORLD_NEAR_Z - (row / zSegments) * WORLD_DEPTH;
|
||||
for (let column = 0; column < columnCount; column += 1) {
|
||||
const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH;
|
||||
positions[vertexOffset] = x;
|
||||
|
||||
31
src/scene/world/travel-distance.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface TravelDistance {
|
||||
advance(deltaSeconds: number, speed: number, roadEnergy: number): number;
|
||||
snapshot(): number;
|
||||
}
|
||||
|
||||
const MAX_FRAME_SECONDS = 0.1;
|
||||
const CRUISE_UNITS_PER_SECOND = 18;
|
||||
const SPEED_UNITS_PER_SECOND = 54;
|
||||
const ENERGY_UNITS_PER_SECOND = 48;
|
||||
|
||||
export function createTravelDistance(): TravelDistance {
|
||||
let distance = 0;
|
||||
|
||||
return {
|
||||
advance(deltaSeconds, speed, roadEnergy) {
|
||||
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return distance;
|
||||
const dt = Math.min(MAX_FRAME_SECONDS, deltaSeconds);
|
||||
distance += dt * (
|
||||
CRUISE_UNITS_PER_SECOND
|
||||
+ clamp01(speed) * SPEED_UNITS_PER_SECOND
|
||||
+ clamp01(roadEnergy) * ENERGY_UNITS_PER_SECOND
|
||||
);
|
||||
return distance;
|
||||
},
|
||||
snapshot: () => distance,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
import { clickNavigationLink, expect, test } from '../helpers/serve-dist';
|
||||
import {
|
||||
installSceneInstrumentation,
|
||||
readSceneInstrumentation,
|
||||
@@ -104,6 +104,65 @@ test('draws only the independently eligible next roadside sign during passive fo
|
||||
expect([...new Set(labels)]).toEqual([oracle.expectedLabel]);
|
||||
});
|
||||
|
||||
test('keeps a readable sign immutable through reversal, resize, and a multi-section jump', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
const oracle = await page.evaluate(() => {
|
||||
const profile = document.getElementById('profile-heading');
|
||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
||||
return {
|
||||
destination: Math.max(1, headingTop - viewportHeight * (0.35 + 0.8)),
|
||||
viewportHeight,
|
||||
};
|
||||
});
|
||||
await page.mouse.wheel(0, 1);
|
||||
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
|
||||
await page.evaluate((nextY) => window.scrollTo(0, nextY), y);
|
||||
await waitForOneAnimationFrame(page);
|
||||
}
|
||||
await page.evaluate((destination) => window.scrollTo(0, destination), oracle.destination);
|
||||
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
|
||||
.toContain('Profile');
|
||||
await page.waitForTimeout(1_250);
|
||||
|
||||
await page.mouse.wheel(0, -120);
|
||||
await page.setViewportSize({ width: 844, height: 390 });
|
||||
await page.evaluate(() => {
|
||||
const target = document.getElementById('selected-builds-heading');
|
||||
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
|
||||
window.scrollTo(0, target.getBoundingClientRect().top + window.scrollY);
|
||||
});
|
||||
await page.waitForTimeout(450);
|
||||
|
||||
const labels = (await readSceneInstrumentation(page)).routeLabelsDrawn;
|
||||
expect([...new Set(labels)]).toEqual(['Profile']);
|
||||
expect((await readSceneInstrumentation(page)).maximumCanvasCount).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('suppresses roadside signs for explicit navigation and history restoration', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
await clickNavigationLink(page, '#selected-builds');
|
||||
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
||||
await page.waitForTimeout(400);
|
||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
||||
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(400);
|
||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
||||
});
|
||||
|
||||
function colorEnergy(color: string): number {
|
||||
const value = Number.parseInt(color.slice(1), 16);
|
||||
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
|
||||
|
||||
@@ -101,16 +101,32 @@ describe('clock-driven sign lifecycle', () => {
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels an unreadable sign on reverse and departs a readable sign cleanly', () => {
|
||||
it('cancels a concealed sign on reverse and departs a visible approaching sign cleanly', () => {
|
||||
const early = createSignController();
|
||||
early.sample(sample());
|
||||
expect(early.sample(sample({ now: 500, direction: -1, deltaY: -20 }))).toBeNull();
|
||||
expect(early.sample(sample({ now: 310, direction: -1, deltaY: -20 }))).toBeNull();
|
||||
expect(early.needsFrame()).toBe(false);
|
||||
|
||||
const readable = createSignController();
|
||||
readable.sample(sample());
|
||||
readable.sample(sample({ now: 1_200, direction: 0 }));
|
||||
expect(readable.sample(sample({ now: 1_500, direction: -1, deltaY: -20 }))).toMatchObject({
|
||||
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 }))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the minimum readable dwell when reverse input requests departure', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0 }));
|
||||
|
||||
expect(controller.sample(sample({ now: 1_500, direction: -1, deltaY: -20 }))).toMatchObject({
|
||||
phase: 'dwell',
|
||||
});
|
||||
expect(controller.sample(sample({ now: 2_599, direction: 0, deltaY: 0 }))).toMatchObject({
|
||||
phase: 'dwell',
|
||||
});
|
||||
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
});
|
||||
@@ -132,18 +148,22 @@ describe('clock-driven sign lifecycle', () => {
|
||||
it.each([
|
||||
['multiple crossed sections', { crossedBoundaries: 2 }],
|
||||
['a jump over 0.75 viewport heights', { deltaY: 601 }],
|
||||
])('departs a readable stale sign cleanly after %s', (_label, overrides) => {
|
||||
])('coalesces %s while preserving the readable dwell', (_label, overrides) => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0 }));
|
||||
|
||||
expect(controller.sample(sample({ now: 1_400, ...overrides }))).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'dwell',
|
||||
});
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
|
||||
targetId: 'profile',
|
||||
phase: 'depart',
|
||||
progress: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
expect(controller.needsFrame()).toBe(true);
|
||||
});
|
||||
|
||||
it('departs when the authoritative section has already passed the latched target', () => {
|
||||
@@ -161,6 +181,33 @@ describe('clock-driven sign lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not truncate dwell when the authoritative section passes the target', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
controller.sample(sample({ now: 1_200, direction: 0 }));
|
||||
|
||||
expect(controller.sample(sample({
|
||||
now: 1_350,
|
||||
direction: 1,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_599,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ phase: 'dwell' });
|
||||
expect(controller.sample(sample({
|
||||
now: 2_600,
|
||||
direction: 0,
|
||||
deltaY: 0,
|
||||
activeSectionId: 'experience',
|
||||
candidateId: 'infrastructure',
|
||||
}))).toMatchObject({ phase: 'depart', progress: 0 });
|
||||
});
|
||||
|
||||
it('retargets a changed far-away candidate only while the sign is concealed', () => {
|
||||
const controller = createSignController();
|
||||
controller.sample(sample());
|
||||
|
||||
@@ -14,11 +14,42 @@ import { createSun } from "@/scene/world/sun";
|
||||
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 { createChaseCamera, createWorldFog } from "@/scene/world/environment";
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
} from "@/scene/world/metrics";
|
||||
import { createSign, routeLabelFor } from "@/scene/world/sign";
|
||||
import { createDriveDynamics } from "@/scene/drive-dynamics";
|
||||
import { createTravelDistance } from "@/scene/world/travel-distance";
|
||||
|
||||
describe("procedural world models", () => {
|
||||
it("uses the same linear horizon interval for scene-owned materials", () => {
|
||||
const fog = createWorldFog(0x07101c);
|
||||
|
||||
expect(fog).toBeInstanceOf(THREE.Fog);
|
||||
expect(fog.near).toBe(HORIZON_FOG_START);
|
||||
expect(fog.far).toBe(HORIZON_FOG_END);
|
||||
});
|
||||
|
||||
it("owns one monotonic forward-distance clock across cruise, input release, and delayed frames", () => {
|
||||
const travel = createTravelDistance();
|
||||
|
||||
const cruise = travel.advance(0.1, 0.18, 0);
|
||||
const accelerated = travel.advance(0.1, 1, 1);
|
||||
const coast = travel.advance(0.1, 0.18, 0);
|
||||
const delayed = travel.advance(4, 0.18, 0);
|
||||
const malformed = travel.advance(Number.NaN, -1, Number.POSITIVE_INFINITY);
|
||||
|
||||
expect(cruise).toBeGreaterThan(0);
|
||||
expect(accelerated).toBeGreaterThan(cruise);
|
||||
expect(coast).toBeGreaterThan(accelerated);
|
||||
expect(delayed).toBeGreaterThan(coast);
|
||||
expect(delayed - coast).toBeCloseTo(cruise, 10);
|
||||
expect(malformed).toBe(delayed);
|
||||
expect(travel.snapshot()).toBe(delayed);
|
||||
});
|
||||
|
||||
it("scales geometry and atmosphere work monotonically by live quality tier", () => {
|
||||
const high = renderQualityFor("high", 3, false);
|
||||
const medium = renderQualityFor("medium", 3, false);
|
||||
@@ -44,10 +75,13 @@ describe("procedural world models", () => {
|
||||
expect(surface.geometry.getAttribute("position").count).toBeGreaterThan(
|
||||
1_000,
|
||||
);
|
||||
surface.geometry.computeBoundingBox();
|
||||
expect(surface.geometry.boundingBox?.min.z).toBeLessThan(-700);
|
||||
expect(surface.material.uniforms).toMatchObject({
|
||||
uDistance: { value: 0 },
|
||||
uSpeed: { value: 0 },
|
||||
uAcceleration: { value: 0 },
|
||||
uFogColor: { value: expect.any(THREE.Color) },
|
||||
});
|
||||
expect(surface.material.fragmentShader).toContain("centerLane");
|
||||
expect(surface.material.fragmentShader).toContain("outerEdge");
|
||||
@@ -80,6 +114,7 @@ describe("procedural world models", () => {
|
||||
expect(lines.geometry.index).not.toBeNull();
|
||||
lines.geometry.computeBoundingBox();
|
||||
expect(lines.geometry.boundingBox).not.toBeNull();
|
||||
expect(lines.geometry.boundingBox?.min.z).toBeLessThan(-700);
|
||||
expect(
|
||||
(lines.geometry.boundingBox?.max.x ?? 0) -
|
||||
(lines.geometry.boundingBox?.min.x ?? 0),
|
||||
@@ -93,8 +128,10 @@ describe("procedural world models", () => {
|
||||
expect(lines.material.uniforms).toMatchObject({
|
||||
uDistance: { value: 0 },
|
||||
uSpeed: { value: 0 },
|
||||
uFlowDistance: { value: 0 },
|
||||
uRouteProgress: { value: 0 },
|
||||
uRelief: { value: expect.any(Number) },
|
||||
uFogColor: { value: expect.any(THREE.Color) },
|
||||
});
|
||||
expect(lines.material.vertexShader).toContain("multiOctaveNoise");
|
||||
expect(lines.material.vertexShader).toContain("mod(point, 256.0)");
|
||||
@@ -163,21 +200,28 @@ describe("procedural world models", () => {
|
||||
const sun = createSun(resources);
|
||||
const disc = sun.group.getObjectByName("horizon-sun") as ShaderRenderable;
|
||||
const halo = sun.group.getObjectByName("horizon-halo");
|
||||
const limb = sun.group.getObjectByName("sun-limb-glow");
|
||||
const bleed = sun.group.getObjectByName("horizon-light-bleed");
|
||||
|
||||
expect(disc).toBeInstanceOf(THREE.Mesh);
|
||||
expect(halo).toBeInstanceOf(THREE.Sprite);
|
||||
expect(limb).toBeInstanceOf(THREE.Mesh);
|
||||
expect(bleed).toBeInstanceOf(THREE.Sprite);
|
||||
expect((disc as unknown as { position: { z: number } }).position.z).toBeLessThan(-650);
|
||||
expect(disc.material.uniforms).toMatchObject({
|
||||
uScanBands: { value: 11 },
|
||||
uWarmth: { value: CHAPTER_PALETTES["deep-night"].warmth },
|
||||
uTime: { value: 0 },
|
||||
uDriveEnergy: { value: 0 },
|
||||
});
|
||||
expect(disc.material.fragmentShader).toContain("scanBand");
|
||||
expect(resources.snapshot().owned.texture).toBe(1);
|
||||
|
||||
const palette = createMutableChapterPalette("destination");
|
||||
sun.update(palette, 1, 3.2);
|
||||
sun.update(palette, 1, 3.2, 1);
|
||||
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
|
||||
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
|
||||
expect(disc.material.uniforms.uDriveEnergy?.value).toBe(1);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
@@ -266,6 +310,12 @@ describe("procedural world models", () => {
|
||||
expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8);
|
||||
expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0);
|
||||
|
||||
high.update(100, 1, 1, palette);
|
||||
const acceleratedPhase = highStreaks.material.uniforms.uDistance?.value as number;
|
||||
high.update(102, 0.18, 0, palette);
|
||||
const coastPhase = highStreaks.material.uniforms.uDistance?.value as number;
|
||||
expect(coastPhase).toBeGreaterThan(acceleratedPhase);
|
||||
|
||||
high.setViewportAspect(32 / 9);
|
||||
expect(sky.material.uniforms.uAspect?.value).toBeCloseTo(32 / 9, 6);
|
||||
|
||||
@@ -510,22 +560,39 @@ describe("procedural world models", () => {
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("renders one physical roadside sign and updates its plain label once per target", () => {
|
||||
it("renders one mounted roadside sign without mutating a visible label", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
for (const name of [
|
||||
"sign-backplate",
|
||||
"sign-face",
|
||||
"sign-frame",
|
||||
"sign-post-left",
|
||||
"sign-post-right",
|
||||
"sign-housing",
|
||||
"sign-edge-light",
|
||||
"sign-post",
|
||||
"sign-bracket",
|
||||
"sign-footing",
|
||||
]) {
|
||||
expect(sign.group.getObjectByName(name), name).toBeDefined();
|
||||
}
|
||||
const face = sign.group.getObjectByName("sign-face") as {
|
||||
userData: { textureUpdateCount: number };
|
||||
material: { opacity: number };
|
||||
geometry: { getAttribute(name: string): { array: ArrayLike<number> } };
|
||||
position: { z: number };
|
||||
};
|
||||
const backplate = sign.group.getObjectByName("sign-backplate") as {
|
||||
geometry: {
|
||||
boundingBox: { max: { z: number } } | null;
|
||||
computeBoundingBox(): void;
|
||||
};
|
||||
position: { z: number };
|
||||
};
|
||||
backplate.geometry.computeBoundingBox();
|
||||
const backplateFront = backplate.position.z + (backplate.geometry.boundingBox?.max.z ?? 0);
|
||||
expect(face.position.z - backplateFront).toBeGreaterThan(0.05);
|
||||
const faceUvs = Array.from(face.geometry.getAttribute("uv").array);
|
||||
expect(Math.min(...faceUvs)).toBeGreaterThanOrEqual(0);
|
||||
expect(Math.max(...faceUvs)).toBeLessThanOrEqual(1);
|
||||
const allocation = resources.snapshot();
|
||||
|
||||
sign.update(null);
|
||||
@@ -550,7 +617,8 @@ describe("procedural world models", () => {
|
||||
expect(face.userData.textureUpdateCount).toBe(1);
|
||||
expect(face.material.opacity).toBeCloseTo(0.7, 6);
|
||||
|
||||
const desktopX = (sign.group as { position: { x: number } }).position.x;
|
||||
const visiblePosition = sign.group.position.clone();
|
||||
const visibleScale = sign.group.scale.x;
|
||||
sign.setViewportAspect(0.6);
|
||||
sign.update({
|
||||
targetId: "selected-builds",
|
||||
@@ -558,14 +626,47 @@ describe("procedural world models", () => {
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
});
|
||||
expect(face.userData.textureUpdateCount).toBe(2);
|
||||
expect(face.userData.textureUpdateCount).toBe(1);
|
||||
expect(routeLabelFor("selected-builds")).toBe("Selected builds");
|
||||
expect((sign.group as { position: { x: number } }).position.x).toBeLessThan(
|
||||
desktopX,
|
||||
);
|
||||
expect(sign.group.position).toEqual(visiblePosition);
|
||||
expect(sign.group.scale.x).toBeCloseTo(visibleScale, 6);
|
||||
|
||||
sign.update(null);
|
||||
sign.update({
|
||||
targetId: "selected-builds",
|
||||
phase: "anticipate",
|
||||
progress: 0,
|
||||
opacity: 0,
|
||||
});
|
||||
expect(face.userData.textureUpdateCount).toBe(2);
|
||||
expect(resources.snapshot()).toEqual(allocation);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("begins sign departure from the rendered approach pose without a teleport", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
sign.setViewportAspect(16 / 9);
|
||||
sign.update({
|
||||
targetId: "profile",
|
||||
phase: "approach",
|
||||
progress: 0.62,
|
||||
opacity: 1,
|
||||
});
|
||||
const approachPosition = sign.group.position.clone();
|
||||
const approachScale = sign.group.scale.x;
|
||||
|
||||
sign.update({
|
||||
targetId: "profile",
|
||||
phase: "depart",
|
||||
progress: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
expect(sign.group.position.distanceTo(approachPosition)).toBeLessThan(0.001);
|
||||
expect(sign.group.scale.x).toBeCloseTo(approachScale, 6);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function paletteSnapshot(
|
||||
@@ -588,7 +689,7 @@ function paletteSnapshot(
|
||||
interface ShaderRenderable {
|
||||
geometry: {
|
||||
index: { array: ArrayLike<number> } | null;
|
||||
boundingBox: { min: { x: number }; max: { x: number } } | null;
|
||||
boundingBox: { min: { x: number; z: number }; max: { x: number; z: number } } | null;
|
||||
computeBoundingBox(): void;
|
||||
getAttribute(name: string): { count: number; array: ArrayLike<number> };
|
||||
};
|
||||
|
||||