Venues: song sky, ride roadside, room camera, filmstrip venue pin
- Shared sky dome (src/venues/sky.js): gradient backdrop from palette / section light / style texture, wired into chamber/stage/ride with fog matched to the dome mid colour — replaces clear-to-black - Ride roadside: houses, signs, trees, seeded viz billboard, all analytic scroll; popMul fade for crew, arrangement-gated roadside (no mid-section popping); parked idle creep + engine shake; analytic dash clock - Room camera: traverse glide + progress look sweep + per-section dolly, single-side room visits, one re-hang max; venue framing per archetype - Filmstrip venue selector (?venueSel=) with cache key; seed persisted to URL on reroll; gallery query plumbing
This commit is contained in:
@@ -88,6 +88,7 @@
|
|||||||
<div class="bar" id="bar">
|
<div class="bar" id="bar">
|
||||||
<span class="spinner"></span>
|
<span class="spinner"></span>
|
||||||
<button id="rebuild">rebuild</button>
|
<button id="rebuild">rebuild</button>
|
||||||
|
<select id="venueSel" title="pin one venue in every row, or census"></select>
|
||||||
<select id="sort">
|
<select id="sort">
|
||||||
<option value="direction">sort: least direction first</option>
|
<option value="direction">sort: least direction first</option>
|
||||||
<option value="drift">sort: least drift first</option>
|
<option value="drift">sort: least drift first</option>
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js';
|
import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js';
|
||||||
|
import { venues } from '/src/venues/registry.js';
|
||||||
import { blit } from '/src/checks/blit.js';
|
import { blit } from '/src/checks/blit.js';
|
||||||
import { hashString } from '/src/engine/rng.js';
|
import { hashString } from '/src/engine/rng.js';
|
||||||
import {
|
import {
|
||||||
@@ -115,6 +117,7 @@ const every = Number(params.get('every')) || DEFAULT_EVERY;
|
|||||||
const duration = Number(params.get('duration')) || DEFAULT_DURATION;
|
const duration = Number(params.get('duration')) || DEFAULT_DURATION;
|
||||||
const only = params.get('song') ? params.get('song').split(',') : null;
|
const only = params.get('song') ? params.get('song').split(',') : null;
|
||||||
const venueEnabled = params.get('venue') === '1';
|
const venueEnabled = params.get('venue') === '1';
|
||||||
|
const venueSelParam = params.get('venueSel') || '';
|
||||||
|
|
||||||
document.getElementById('everyLabel').textContent = every;
|
document.getElementById('everyLabel').textContent = every;
|
||||||
|
|
||||||
@@ -122,9 +125,21 @@ const bar = document.getElementById('bar');
|
|||||||
const out = document.getElementById('out');
|
const out = document.getElementById('out');
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
const sortSel = document.getElementById('sort');
|
const sortSel = document.getElementById('sort');
|
||||||
|
const venueSel = document.getElementById('venueSel');
|
||||||
const progress = document.querySelector('#progress i');
|
const progress = document.querySelector('#progress i');
|
||||||
const rebuildBtn = document.getElementById('rebuild');
|
const rebuildBtn = document.getElementById('rebuild');
|
||||||
|
|
||||||
|
venueSel.innerHTML = `<option value="">all venues (census)</option>` +
|
||||||
|
venues.map((v) => `<option value="${v.name}">${v.name} (${v.archetype})</option>`).join('');
|
||||||
|
if ([...venueSel.options].some((o) => o.value === venueSelParam)) venueSel.value = venueSelParam;
|
||||||
|
venueSel.addEventListener('change', () => {
|
||||||
|
const p = new URLSearchParams(location.search);
|
||||||
|
if (venueSel.value) p.set('venueSel', venueSel.value);
|
||||||
|
else p.delete('venueSel');
|
||||||
|
history.replaceState(null, '', `${location.pathname}?${p}`);
|
||||||
|
build();
|
||||||
|
});
|
||||||
|
|
||||||
let rows = [];
|
let rows = [];
|
||||||
|
|
||||||
// Read against the arcless reference the variety report prints: around zero is
|
// Read against the arcless reference the variety report prints: around zero is
|
||||||
@@ -224,7 +239,8 @@ function summarise(built) {
|
|||||||
? rows.reduce((a, r) => a + (r.direction || 0), 0) / rows.length : 0;
|
? rows.reduce((a, r) => a + (r.direction || 0), 0) / rows.length : 0;
|
||||||
const meanAgit = rows.length
|
const meanAgit = rows.length
|
||||||
? rows.reduce((a, r) => a + (r.agitation ?? 0), 0) / rows.length : 0;
|
? rows.reduce((a, r) => a + (r.agitation ?? 0), 0) / rows.length : 0;
|
||||||
return `${rows.length} songs · every ${every}s of ${duration}s · ` +
|
const venueNote = venueSel.value ? ` · venue ${venueSel.value}` : (venueEnabled ? ' · venues from census' : '');
|
||||||
|
return `${rows.length} songs · every ${every}s of ${duration}s${venueNote} · ` +
|
||||||
`mean direction ${meanDir.toFixed(2)} · ${flat} with no direction` +
|
`mean direction ${meanDir.toFixed(2)} · ${flat} with no direction` +
|
||||||
` · mean agit ${meanAgit.toFixed(2)} · ${agited} agitated (≥${AGIT_CEILING.toFixed(2)})${validatedNote}` +
|
` · mean agit ${meanAgit.toFixed(2)} · ${agited} agitated (≥${AGIT_CEILING.toFixed(2)})${validatedNote}` +
|
||||||
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
|
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
|
||||||
@@ -232,7 +248,7 @@ function summarise(built) {
|
|||||||
|
|
||||||
// The cache key carries the sampling parameters as well as the source, so
|
// The cache key carries the sampling parameters as well as the source, so
|
||||||
// changing `?every=` does not silently show you the previous spacing's pixels.
|
// changing `?every=` does not silently show you the previous spacing's pixels.
|
||||||
const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:v${venueEnabled ? 1 : 0}:${sourceFingerprint()}`;
|
const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:v${venueEnabled ? 1 : 0}:${venueSel.value || 'auto'}:${sourceFingerprint()}`;
|
||||||
|
|
||||||
async function build() {
|
async function build() {
|
||||||
bar.classList.add('building');
|
bar.classList.add('building');
|
||||||
@@ -242,7 +258,7 @@ async function build() {
|
|||||||
|
|
||||||
const total = (only || songNames()).length;
|
const total = (only || songNames()).length;
|
||||||
const built = await buildFilmstrip({
|
const built = await buildFilmstrip({
|
||||||
duration, every, names: only, venueEnabled,
|
duration, every, names: only, venueEnabled, venueSel: venueSel.value || null,
|
||||||
// Painted as they land, in bank order, rather than after the whole run.
|
// Painted as they land, in bank order, rather than after the whole run.
|
||||||
// A full build is a couple of minutes of GPU work and the interesting
|
// A full build is a couple of minutes of GPU work and the interesting
|
||||||
// failure — a strip whose frames could be shuffled — is visible in the
|
// failure — a strip whose frames could be shuffled — is visible in the
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
import { Show, FADE_SECONDS } from '../Show.js';
|
import { Show, FADE_SECONDS } from '../Show.js';
|
||||||
import { generateLook } from '../look/LookGenerator.js';
|
import { generateLook } from '../look/LookGenerator.js';
|
||||||
|
import { venues } from '../venues/registry.js';
|
||||||
import { song, SONGS } from '../audio/songbank.js';
|
import { song, SONGS } from '../audio/songbank.js';
|
||||||
import { hashString } from '../engine/rng.js';
|
import { hashString } from '../engine/rng.js';
|
||||||
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
||||||
@@ -69,12 +70,22 @@ function blockMean(byBlock) {
|
|||||||
*
|
*
|
||||||
* @returns {{name, frames: Array, drift: number, direction: number, look}}
|
* @returns {{name, frames: Array, drift: number, direction: number, look}}
|
||||||
*/
|
*/
|
||||||
export function renderSong(show, name, { duration, every, venueEnabled = null }) {
|
export function renderSong(show, name, { duration, every, venueEnabled = null, venueSel = null }) {
|
||||||
const { track } = song(name, { duration });
|
const { track } = song(name, { duration });
|
||||||
// Seeded off the song's name, so a strip is stable between rebuilds and two
|
// Seeded off the song's name, so a strip is stable between rebuilds and two
|
||||||
// songs are not accidentally handed the same look.
|
// songs are not accidentally handed the same look.
|
||||||
const venueFlag = venueEnabled !== null ? !!venueEnabled : (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venue') === '1');
|
const venueFlag = venueEnabled !== null ? !!venueEnabled : (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venue') === '1');
|
||||||
const look = generateLook(track, { seed: hashString(name), venueEnabled: venueFlag });
|
const look = generateLook(track, { seed: hashString(name), venueEnabled: venueFlag });
|
||||||
|
// Pin one venue in every row (same shell-swap as the venue gallery): the
|
||||||
|
// VenueSpec population stays the census one, only the shell is forced.
|
||||||
|
const sel = venueSel || (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venueSel')) || null;
|
||||||
|
if (sel && look.venue) {
|
||||||
|
const picked = venues.find((v) => v.name === sel);
|
||||||
|
if (!picked) throw new Error(`unknown venue "${sel}"`);
|
||||||
|
look.venue.module = picked;
|
||||||
|
look.venue.name = picked.name;
|
||||||
|
look.venue.archetype = picked.archetype;
|
||||||
|
}
|
||||||
show.useTrack(track, look);
|
show.useTrack(track, look);
|
||||||
|
|
||||||
const frames = [];
|
const frames = [];
|
||||||
@@ -208,6 +219,7 @@ function momentAt(story, index) {
|
|||||||
*/
|
*/
|
||||||
export async function buildFilmstrip({
|
export async function buildFilmstrip({
|
||||||
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null, venueEnabled = null,
|
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null, venueEnabled = null,
|
||||||
|
venueSel = null,
|
||||||
onSong = null, onSongStart = null,
|
onSong = null, onSongStart = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const list = names && names.length ? names : songNames();
|
const list = names && names.length ? names : songNames();
|
||||||
@@ -225,7 +237,7 @@ export async function buildFilmstrip({
|
|||||||
}
|
}
|
||||||
let row;
|
let row;
|
||||||
try {
|
try {
|
||||||
row = renderSong(show, list[i], { duration, every, venueEnabled });
|
row = renderSong(show, list[i], { duration, every, venueEnabled, venueSel });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
row = { name: list[i], frames: [], shots: [], drift: 0, direction: 0, error: err.message };
|
row = { name: list[i], frames: [], shots: [], drift: 0, direction: 0, error: err.message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,15 +224,62 @@ export class Compositor {
|
|||||||
const rise = (trav - 0.5) * 0.9 * room;
|
const rise = (trav - 0.5) * 0.9 * room;
|
||||||
const shiftX = framing.shift[0] * 0.6;
|
const shiftX = framing.shift[0] * 0.6;
|
||||||
const shiftY = framing.shift[1] * 0.35;
|
const shiftY = framing.shift[1] * 0.35;
|
||||||
|
let px = walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx;
|
||||||
|
let py = 0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy;
|
||||||
|
let pz = dolly;
|
||||||
|
if (venue.archetype === 'ride') {
|
||||||
|
// Orbit the car once per video (~200° arc, seeded direction)
|
||||||
|
// ON TOP of the traverse: the chase unit circles while it
|
||||||
|
// pushes in, so angles genuinely change instead of the
|
||||||
|
// camera shuffling on one side. Monotonic in progress — the
|
||||||
|
// arc survives — and bounded by the same box below.
|
||||||
|
// Radius tracks the shot dolly (wides orbit far, closes
|
||||||
|
// near) with a floor that keeps the camera out of the
|
||||||
|
// bodywork: the car is ~2 wide and ~3.5 long, and a 2.6
|
||||||
|
// floor still put close-ups through the paint.
|
||||||
|
const oDir = s1 >= 0 ? 1 : -1;
|
||||||
|
const oAng = oDir * (0.5 + trav * 3.5);
|
||||||
|
const oRad = Math.max(3.2, dolly);
|
||||||
|
// Blend orbit with traverse by section energy of the gaze:
|
||||||
|
// keep half the traverse so the walk's travel isn't lost.
|
||||||
|
px = (px + Math.sin(oAng) * oRad) / 2;
|
||||||
|
pz = (pz + Math.cos(oAng) * oRad) / 2;
|
||||||
|
py = (py + (0.5 + Math.sin(trav * 6.28 + s2 * 3.0) * 0.9)) / 2;
|
||||||
|
}
|
||||||
|
// Minimum focus distance from the car (origin): blending can
|
||||||
|
// still land the camera inside the paint, where a frame is
|
||||||
|
// nothing but chassis. Push out along the xz ray, keep y.
|
||||||
|
{
|
||||||
|
const dx = px, dz = pz;
|
||||||
|
const r = Math.hypot(dx, dz);
|
||||||
|
const minR = venue.archetype === 'ride' ? 3.4 : 0;
|
||||||
|
if (r < minR && r > 1e-4) {
|
||||||
|
const k = minR / r;
|
||||||
|
px = dx * k; pz = dz * k;
|
||||||
|
} else if (r <= 1e-4) {
|
||||||
|
px = minR; pz = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
cam.position.set(
|
cam.position.set(
|
||||||
walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx,
|
Math.max(-lim.x, Math.min(lim.x, px)),
|
||||||
Math.max(lim.yLo, Math.min(lim.yHi,
|
Math.max(lim.yLo, Math.min(lim.yHi, py)),
|
||||||
0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy)),
|
Math.max(lim.zLo, Math.min(lim.zHi, pz)),
|
||||||
Math.max(lim.zLo, Math.min(lim.zHi, dolly)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
|
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
|
||||||
cam.lookAt(planned.look[0], planned.look[1], planned.look[2]);
|
let lx = planned.look[0], ly = planned.look[1], lz = planned.look[2];
|
||||||
|
if (venue.archetype === 'ride') {
|
||||||
|
// Track the car: the orbit swings wide, and the planned look
|
||||||
|
// alone lets the vehicle leave the frame (measured: empty-road
|
||||||
|
// shots). Blend the plan's motive look with the car's analytic
|
||||||
|
// position (same formula as the module sweep) so the chase
|
||||||
|
// unit keeps its subject while still panning with motive.
|
||||||
|
const carX = Math.sin(time * 0.133 + ((venue.seed >>> 0) || 1) * 0.001) * 1.1;
|
||||||
|
lx = carX * 0.55 + lx * 0.45;
|
||||||
|
ly = 0.3 * 0.55 + ly * 0.45;
|
||||||
|
lz = -0.3 * 0.55 + lz * 0.45;
|
||||||
|
}
|
||||||
|
cam.lookAt(lx, ly, lz);
|
||||||
cam.updateMatrixWorld();
|
cam.updateMatrixWorld();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,13 +49,15 @@ export const VENUE_STAGING = {
|
|||||||
box: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 },
|
box: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 },
|
||||||
dolly: { wide: 8.2, normal: 4.6, close: 2.4 },
|
dolly: { wide: 8.2, normal: 4.6, close: 2.4 },
|
||||||
},
|
},
|
||||||
// The ride: the "screen" is the dash radio on its stalk at (0, 1.15,
|
// The ride: the "screen" is the small dash radio at (0, 0.82, -0.8),
|
||||||
// 0.9), facing +z toward the default camera; the car sits at the origin
|
// facing +z (rearward) at the passengers; the car sits at the origin
|
||||||
// driving toward -z. The camera works the car like a chase unit — orbits,
|
// driving toward -z. Screen visits shoot from the rear hemisphere (the
|
||||||
// push-ins on the radio, pull-backs for the road — inside a box that keeps
|
// only side the radio faces); the camera works the car like a chase unit
|
||||||
// it out of the bodywork.
|
// otherwise — orbits, pull-backs for the road, and passerby plants it on
|
||||||
|
// the shoulder while the car sweeps through — inside a box that keeps it
|
||||||
|
// out of the bodywork.
|
||||||
ride: {
|
ride: {
|
||||||
screen: [0, 1.15, 0.9],
|
screen: [0, 0.82, -0.8],
|
||||||
fieldL: [-3.4, 0.2, -3.0],
|
fieldL: [-3.4, 0.2, -3.0],
|
||||||
fieldR: [3.4, 0.2, -3.0],
|
fieldR: [3.4, 0.2, -3.0],
|
||||||
roomMid: [0, 0.3, -6],
|
roomMid: [0, 0.3, -6],
|
||||||
|
|||||||
@@ -291,9 +291,26 @@ document.addEventListener('keydown', (e) => {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- look edits
|
// ---------------------------------------------------------------- look edits
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the current look seed into the URL so a reload restores it.
|
||||||
|
*
|
||||||
|
* Only meaningful with `?song=` (bank songs load by name; a dropped file has
|
||||||
|
* no URL identity, so a seed alone restores nothing). Uses replaceState — no
|
||||||
|
* reload, no history spam.
|
||||||
|
*/
|
||||||
|
function persistSeedInUrl() {
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
if (!q.get('song')) return;
|
||||||
|
q.set('seed', String(state.show.look.seed >>> 0));
|
||||||
|
history.replaceState(null, '', `${location.pathname}?${q}`);
|
||||||
|
} catch { /* non-browser or sandboxed iframe — seeding still works in-memory */ }
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('btn-reroll').addEventListener('click', () => {
|
document.getElementById('btn-reroll').addEventListener('click', () => {
|
||||||
if (!state.show.ready) return;
|
if (!state.show.ready) return;
|
||||||
state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
|
state.show.reroll((state.show.look.seed ^ (++state.rerollSalt * 0x9e3779b9)) >>> 0);
|
||||||
|
persistSeedInUrl();
|
||||||
refreshAfterLookChange();
|
refreshAfterLookChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// Primitives only in V1 — no glTF kit.
|
// Primitives only in V1 — no glTF kit.
|
||||||
|
|
||||||
import { actorToGeometry } from '../actors/meshes.js';
|
import { actorToGeometry } from '../actors/meshes.js';
|
||||||
|
import { buildSky, paintSky } from './sky.js';
|
||||||
|
|
||||||
export const chamber = {
|
export const chamber = {
|
||||||
name: 'Chamber',
|
name: 'Chamber',
|
||||||
@@ -212,6 +213,11 @@ export const chamber = {
|
|||||||
scene.fog = new T.Fog(0x0e0e12, 12, 28);
|
scene.fog = new T.Fog(0x0e0e12, 12, 28);
|
||||||
scene.userData.transparentBackground = true;
|
scene.userData.transparentBackground = true;
|
||||||
|
|
||||||
|
// Song sky above the open room — replaces clear-to-black. Radius 90
|
||||||
|
// clears the 22-unit room with margin; fog melts the horizon.
|
||||||
|
const sky = buildSky(T, { radius: 90 });
|
||||||
|
scene.add(sky.mesh);
|
||||||
|
|
||||||
// Population container — memories
|
// Population container — memories
|
||||||
const popGroup = new T.Group();
|
const popGroup = new T.Group();
|
||||||
popGroup.name = 'memories';
|
popGroup.name = 'memories';
|
||||||
@@ -221,6 +227,7 @@ export const chamber = {
|
|||||||
room, floor, floorMat, wallMat, walls, windowMesh, windowMat,
|
room, floor, floorMat, wallMat, walls, windowMesh, windowMat,
|
||||||
reflection, reflectMat,
|
reflection, reflectMat,
|
||||||
ambient, sun, fill,
|
ambient, sun, fill,
|
||||||
|
skyMesh: sky.mesh, skyMat: sky.mat,
|
||||||
popGroup,
|
popGroup,
|
||||||
seed,
|
seed,
|
||||||
venueSpec: venueSpec || null,
|
venueSpec: venueSpec || null,
|
||||||
@@ -305,6 +312,12 @@ export const chamber = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Song sky + matched fog. The sky replaces clear-to-black; fog takes
|
||||||
|
// the dome's mid colour so the horizon melts instead of banding.
|
||||||
|
paintSky(instance.skyMat, {
|
||||||
|
palette: pal, light: roomLight, style,
|
||||||
|
seed: instance.seed,
|
||||||
|
});
|
||||||
// Fog via space + story: resolution (fog 0.55) closes the room in,
|
// Fog via space + story: resolution (fog 0.55) closes the room in,
|
||||||
// climax (0.18) opens it. Swing near AND far so the depth change reads.
|
// climax (0.18) opens it. Swing near AND far so the depth change reads.
|
||||||
if (scene.fog) {
|
if (scene.fog) {
|
||||||
@@ -312,7 +325,9 @@ export const chamber = {
|
|||||||
const fogDepth = (space ? 12 + space.depth * 6 : 15) * (0.7 + fogMul * 0.75);
|
const fogDepth = (space ? 12 + space.depth * 6 : 15) * (0.7 + fogMul * 0.75);
|
||||||
scene.fog.near = fogDepth;
|
scene.fog.near = fogDepth;
|
||||||
scene.fog.far = fogDepth + 10 + fogMul * 10;
|
scene.fog.far = fogDepth + 10 + fogMul * 10;
|
||||||
scene.fog.color.setRGB(c0[0] * 0.06, c0[1] * 0.06, c0[2] * 0.08);
|
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
|
||||||
|
if (mid) scene.fog.color.copy(mid);
|
||||||
|
else scene.fog.color.setRGB(c0[0] * 0.06, c0[1] * 0.06, c0[2] * 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild population if needed (deterministic, seeded). The arrangement
|
// Rebuild population if needed (deterministic, seeded). The arrangement
|
||||||
|
|||||||
+369
-40
@@ -10,6 +10,7 @@
|
|||||||
// ArcDriver (never stepped).
|
// ArcDriver (never stepped).
|
||||||
|
|
||||||
import { actorToGeometry } from '../actors/meshes.js';
|
import { actorToGeometry } from '../actors/meshes.js';
|
||||||
|
import { buildSky, paintSky } from './sky.js';
|
||||||
|
|
||||||
export const ride = {
|
export const ride = {
|
||||||
name: 'Ride',
|
name: 'Ride',
|
||||||
@@ -35,13 +36,18 @@ export const ride = {
|
|||||||
|
|
||||||
// Venue RTT: the compositor calls this with the finished viz texture after
|
// Venue RTT: the compositor calls this with the finished viz texture after
|
||||||
// compositing the viz stack into vizTarget. The radio screen samples it,
|
// compositing the viz stack into vizTarget. The radio screen samples it,
|
||||||
// so the viz plays ON the dash rather than composited over the venue.
|
// so the viz plays ON the dash rather than composited over the venue —
|
||||||
|
// and so does the roadside billboard when the seed placed one.
|
||||||
bindVizTexture(instance, texture) {
|
bindVizTexture(instance, texture) {
|
||||||
if (!instance || !texture) return;
|
if (!instance || !texture) return;
|
||||||
if (instance.radioMat && instance.radioMat.emissiveMap !== texture) {
|
if (instance.radioMat && instance.radioMat.emissiveMap !== texture) {
|
||||||
instance.radioMat.emissiveMap = texture;
|
instance.radioMat.emissiveMap = texture;
|
||||||
instance.radioMat.needsUpdate = true;
|
instance.radioMat.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
if (instance.billMat && instance.billMat.emissiveMap !== texture) {
|
||||||
|
instance.billMat.emissiveMap = texture;
|
||||||
|
instance.billMat.needsUpdate = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Radio-glow wash for the viz composite — arcs with venueState.light.
|
// Radio-glow wash for the viz composite — arcs with venueState.light.
|
||||||
@@ -88,6 +94,73 @@ export const ride = {
|
|||||||
}
|
}
|
||||||
scene.add(dashes);
|
scene.add(dashes);
|
||||||
|
|
||||||
|
// Chevron strips: wide faint chevrons across the lane that stream with
|
||||||
|
// the dashes — road texture with no texture map. Same scroll loop.
|
||||||
|
const chevMat = new T.MeshBasicMaterial({
|
||||||
|
color: new T.Color(0.35, 0.38, 0.5), transparent: true, opacity: 0.28,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const chevGeo = new T.PlaneGeometry(5.6, 0.5);
|
||||||
|
const chevrons = new T.Group();
|
||||||
|
chevrons.name = 'chevrons';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const c = new T.Mesh(chevGeo, chevMat);
|
||||||
|
c.rotation.x = -Math.PI / 2;
|
||||||
|
c.position.set(0, -1.14, -72 + i * 10);
|
||||||
|
chevrons.add(c);
|
||||||
|
}
|
||||||
|
scene.add(chevrons);
|
||||||
|
|
||||||
|
// Wind streaks: faint long quads at the frame edges rushing past in
|
||||||
|
// chase mode — pure speed read, no geometry to hit. Billboarded
|
||||||
|
// manually each frame (see update); analytic scroll like everything.
|
||||||
|
const streakMat = new T.MeshBasicMaterial({
|
||||||
|
color: new T.Color(0.5, 0.55, 0.7), transparent: true, opacity: 0.16,
|
||||||
|
depthWrite: false, side: T.DoubleSide,
|
||||||
|
});
|
||||||
|
const streakGeo = new T.PlaneGeometry(0.06, 7);
|
||||||
|
const streaks = new T.Group();
|
||||||
|
streaks.name = 'streaks';
|
||||||
|
const streakSeeds = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const s = new T.Mesh(streakGeo, streakMat);
|
||||||
|
const side = i % 2 === 0 ? -1 : 1;
|
||||||
|
s.position.set(side * (5.5 + (i % 5)), 1.5 + ((i * 37) % 40) / 10, -60 + i * 7);
|
||||||
|
s.userData.baseX = s.position.x;
|
||||||
|
s.userData.baseY = s.position.y;
|
||||||
|
streaks.add(s);
|
||||||
|
streakSeeds.push(i * 0.7);
|
||||||
|
}
|
||||||
|
scene.add(streaks);
|
||||||
|
|
||||||
|
// Roadside posts: the stuff that streams past — alternating near/far
|
||||||
|
// sentinels off both shoulders, palette-lit, so chase speed reads even
|
||||||
|
// with eyes closed to the dashes. Analytic scroll (see update).
|
||||||
|
const postMat = new T.MeshStandardMaterial({
|
||||||
|
color: new T.Color(0.08, 0.08, 0.09),
|
||||||
|
emissive: new T.Color(0.4, 0.5, 0.75),
|
||||||
|
emissiveIntensity: 0.55,
|
||||||
|
roughness: 0.7,
|
||||||
|
});
|
||||||
|
const postGeo = new T.BoxGeometry(0.28, 2.6, 0.28);
|
||||||
|
const capGeo = new T.BoxGeometry(0.4, 0.22, 0.4);
|
||||||
|
const posts = new T.Group();
|
||||||
|
posts.name = 'posts';
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const g = new T.Group();
|
||||||
|
const side = i % 2 === 0 ? -1 : 1;
|
||||||
|
const near = i % 4 < 2;
|
||||||
|
const pole = new T.Mesh(postGeo, postMat);
|
||||||
|
const cap = new T.Mesh(capGeo, postMat);
|
||||||
|
cap.position.y = 1.35;
|
||||||
|
g.add(pole); g.add(cap);
|
||||||
|
// Far shoulder: angular velocity falls with distance, so pushed-out
|
||||||
|
// posts glide instead of whipping past (measured flip-by at ±4.6).
|
||||||
|
g.position.set(side * (near ? 7 : 12), 0.15, -78 + i * 6);
|
||||||
|
posts.add(g);
|
||||||
|
}
|
||||||
|
scene.add(posts);
|
||||||
|
|
||||||
// Lane edge glow: two long emissive strips so the road reads as a
|
// Lane edge glow: two long emissive strips so the road reads as a
|
||||||
// road even when the asphalt is near-black.
|
// road even when the asphalt is near-black.
|
||||||
const edgeMat = new T.MeshStandardMaterial({
|
const edgeMat = new T.MeshStandardMaterial({
|
||||||
@@ -106,6 +179,122 @@ export const ride = {
|
|||||||
edges.push(edge);
|
edges.push(edge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roadside set dressing: houses, road signs, trees, and one seeded
|
||||||
|
// billboard that can carry the viz. All primitives, all palette-lit,
|
||||||
|
// all scrolling with the same analytic span as the dashes (see
|
||||||
|
// update) — the world streams past in chase mode, holds in parked.
|
||||||
|
// Seeded per arrangement so a story jump re-hangs the roadside too.
|
||||||
|
const dressMat = new T.MeshStandardMaterial({
|
||||||
|
color: new T.Color(0.07, 0.07, 0.08),
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0.05,
|
||||||
|
});
|
||||||
|
const leafMat = new T.MeshStandardMaterial({
|
||||||
|
color: new T.Color(0.05, 0.09, 0.05),
|
||||||
|
emissive: new T.Color(0.1, 0.3, 0.12),
|
||||||
|
emissiveIntensity: 0.35,
|
||||||
|
roughness: 0.9,
|
||||||
|
});
|
||||||
|
const signMat = new T.MeshStandardMaterial({
|
||||||
|
color: new T.Color(0.06, 0.06, 0.07),
|
||||||
|
emissive: new T.Color(0.85, 0.7, 0.3),
|
||||||
|
emissiveIntensity: 0.6,
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
const billMat = new T.MeshStandardMaterial({
|
||||||
|
color: new T.Color(0.02, 0.02, 0.02),
|
||||||
|
emissive: new T.Color(1, 1, 1),
|
||||||
|
emissiveIntensity: 1.1,
|
||||||
|
roughness: 0.6,
|
||||||
|
});
|
||||||
|
const roadside = new T.Group();
|
||||||
|
roadside.name = 'roadside';
|
||||||
|
// 12 slots spaced 8 over [-84, +4]: house / sign / trees / billboard.
|
||||||
|
// Billboard appears at most once, on one side, placed mid-span so it
|
||||||
|
// is on screen for a stretch, not a flash.
|
||||||
|
const billSlot = 3 + Math.floor(rng() * 6);
|
||||||
|
const billSide = rng() < 0.5 ? -1 : 1;
|
||||||
|
const roadsideMeta = [];
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const g = new T.Group();
|
||||||
|
const side = i % 2 === 0 ? -1 : 1;
|
||||||
|
const kind = i === billSlot ? 'billboard'
|
||||||
|
: i % 3 === 0 ? 'house' : i % 3 === 1 ? 'sign' : 'trees';
|
||||||
|
// Dressing lives far out (houses ±13-18, signs/trees ±9-13): near
|
||||||
|
// items cross the frame in a blink, far items linger and let the
|
||||||
|
// camera actually see them. Scaled up ~1.3x with distance so they
|
||||||
|
// don't shrink to dots.
|
||||||
|
const bx = side * (kind === 'house' ? 13 + rng() * 5 : 9 + rng() * 4);
|
||||||
|
const farK = kind === 'house' ? 1.35 : 1.2;
|
||||||
|
if (kind === 'house') {
|
||||||
|
// Box + prism roof + one lit window quad.
|
||||||
|
const w = 2.6 + rng() * 1.6, h = 1.8 + rng() * 1.2, d = 2.2 + rng() * 1.2;
|
||||||
|
const walls = new T.Mesh(new T.BoxGeometry(w, h, d), dressMat);
|
||||||
|
walls.position.y = h / 2 - 1.15;
|
||||||
|
g.add(walls);
|
||||||
|
const roof = new T.Mesh(new T.ConeGeometry(Math.max(w, d) * 0.72, 1.1 + rng() * 0.6, 4), dressMat);
|
||||||
|
roof.position.y = h - 1.15 + 0.55;
|
||||||
|
roof.rotation.y = Math.PI / 4;
|
||||||
|
g.add(roof);
|
||||||
|
const win = new T.Mesh(new T.PlaneGeometry(0.5 + rng() * 0.4, 0.6),
|
||||||
|
new T.MeshStandardMaterial({
|
||||||
|
color: new T.Color(0.05, 0.05, 0.05),
|
||||||
|
emissive: new T.Color(0.95, 0.75, 0.4),
|
||||||
|
emissiveIntensity: 0.9, roughness: 0.6,
|
||||||
|
}));
|
||||||
|
win.position.set((rng() - 0.5) * w * 0.5, 0.1 + rng() * 0.5, d / 2 + 0.01);
|
||||||
|
if (side > 0) win.rotation.y = Math.PI;
|
||||||
|
g.add(win);
|
||||||
|
g.userData.winMat = win.material;
|
||||||
|
} else if (kind === 'sign') {
|
||||||
|
// Pole + reflective diamond.
|
||||||
|
const pole = new T.Mesh(new T.CylinderGeometry(0.06, 0.06, 2.2, 6), dressMat);
|
||||||
|
pole.position.y = -0.05;
|
||||||
|
g.add(pole);
|
||||||
|
const diamond = new T.Mesh(new T.BoxGeometry(0.7, 0.7, 0.06), signMat);
|
||||||
|
diamond.position.y = 1.25;
|
||||||
|
diamond.rotation.z = Math.PI / 4;
|
||||||
|
diamond.rotation.y = side > 0 ? -0.35 : 0.35;
|
||||||
|
g.add(diamond);
|
||||||
|
} else if (kind === 'billboard') {
|
||||||
|
// Two posts + big panel carrying the viz (bound in
|
||||||
|
// bindVizTexture alongside the radio). Faces the road.
|
||||||
|
const pGeo = new T.CylinderGeometry(0.12, 0.12, 3.4, 6);
|
||||||
|
for (const px of [-1.4, 1.4]) {
|
||||||
|
const post = new T.Mesh(pGeo, dressMat);
|
||||||
|
post.position.set(px, 0.55, 0);
|
||||||
|
g.add(post);
|
||||||
|
}
|
||||||
|
const panel = new T.Mesh(new T.PlaneGeometry(4.4, 2.5), billMat);
|
||||||
|
panel.position.y = 3.1;
|
||||||
|
// Faces rearward (+z, toward the chase camera and the car it
|
||||||
|
// follows): a billboard is for approaching traffic, and our
|
||||||
|
// traffic approaches from +z. Slight toe-in per side so it
|
||||||
|
// isn't a flat wall from the shoulder.
|
||||||
|
panel.rotation.y = side < 0 ? 0.18 : -0.18;
|
||||||
|
panel.name = 'billboard';
|
||||||
|
g.add(panel);
|
||||||
|
g.userData.billPanel = panel;
|
||||||
|
} else {
|
||||||
|
// Trees: 2-3 cones on cylinders, dark green glow.
|
||||||
|
const n = 2 + Math.floor(rng() * 2);
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const th = 1.6 + rng() * 1.6;
|
||||||
|
const trunk = new T.Mesh(new T.CylinderGeometry(0.09, 0.12, 0.8, 5), dressMat);
|
||||||
|
trunk.position.set((rng() - 0.5) * 2.2, -0.75, (rng() - 0.5) * 2.2);
|
||||||
|
const crown = new T.Mesh(new T.ConeGeometry(0.65 + rng() * 0.4, th, 7), leafMat);
|
||||||
|
crown.position.copy(trunk.position);
|
||||||
|
crown.position.y += 0.4 + th / 2;
|
||||||
|
g.add(trunk); g.add(crown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.position.set(bx, 0, -84 + i * 8);
|
||||||
|
g.scale.setScalar(farK);
|
||||||
|
roadside.add(g);
|
||||||
|
roadsideMeta.push({ kind, side, baseX: bx });
|
||||||
|
}
|
||||||
|
scene.add(roadside);
|
||||||
|
|
||||||
// The car: a group at the origin facing -z (driving away from camera
|
// The car: a group at the origin facing -z (driving away from camera
|
||||||
// start). Body panels come from the track's actor; chassis/cabin/wheels
|
// start). Body panels come from the track's actor; chassis/cabin/wheels
|
||||||
// are seeded primitives so every song's vehicle differs.
|
// are seeded primitives so every song's vehicle differs.
|
||||||
@@ -128,15 +317,23 @@ export const ride = {
|
|||||||
scene.fog = new T.Fog(0x07070c, 14, 46);
|
scene.fog = new T.Fog(0x07070c, 14, 46);
|
||||||
scene.userData.transparentBackground = true;
|
scene.userData.transparentBackground = true;
|
||||||
|
|
||||||
|
// Song sky over the night road — radius 90 clears the 120-unit road.
|
||||||
|
const sky = buildSky(T, { radius: 90 });
|
||||||
|
scene.add(sky.mesh);
|
||||||
|
|
||||||
// Passenger container — actor riders seated in the cabin.
|
// Passenger container — actor riders seated in the cabin.
|
||||||
const crew = new T.Group();
|
const crew = new T.Group();
|
||||||
crew.name = 'crew';
|
crew.name = 'crew';
|
||||||
car.add(crew);
|
car.add(crew);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
car, road, roadMat, dashes, dashMat, edges, edgeMat, crew,
|
car, road, roadMat, dashes, dashMat, chevrons, chevMat, streaks, streakMat, streakSeeds,
|
||||||
|
posts, postMat, edges, edgeMat,
|
||||||
|
roadside, roadsideMeta, dressMat, leafMat, signMat, billMat,
|
||||||
|
skyMesh: sky.mesh, skyMat: sky.mat,
|
||||||
|
crew,
|
||||||
ambient, key, tail,
|
ambient, key, tail,
|
||||||
radioMat: null, radioMesh: null,
|
radioMat: null, radioMesh: null, billPanel: null,
|
||||||
bodyMeshes: [],
|
bodyMeshes: [],
|
||||||
seed,
|
seed,
|
||||||
venueSpec: venueSpec || null,
|
venueSpec: venueSpec || null,
|
||||||
@@ -182,12 +379,19 @@ export const ride = {
|
|||||||
if (instance.key) instance.key.intensity = 0.7 + fieldLight * 1.1;
|
if (instance.key) instance.key.intensity = 0.7 + fieldLight * 1.1;
|
||||||
if (instance.ambient) instance.ambient.intensity = 0.4 + fieldLight * 0.5;
|
if (instance.ambient) instance.ambient.intensity = 0.4 + fieldLight * 0.5;
|
||||||
if (instance.tail) instance.tail.intensity = 1.2 + fieldLight * 2;
|
if (instance.tail) instance.tail.intensity = 1.2 + fieldLight * 2;
|
||||||
|
// Song sky + matched fog, same contract as chamber/stage.
|
||||||
|
paintSky(instance.skyMat, {
|
||||||
|
palette: pal, light: fieldLight, style,
|
||||||
|
seed: instance.seed,
|
||||||
|
});
|
||||||
if (scene.fog) {
|
if (scene.fog) {
|
||||||
const fogMul = vState ? vState.fog : 0.35;
|
const fogMul = vState ? vState.fog : 0.35;
|
||||||
const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
|
const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
|
||||||
scene.fog.near = fogDepth;
|
scene.fog.near = fogDepth;
|
||||||
scene.fog.far = fogDepth + 30;
|
scene.fog.far = fogDepth + 30;
|
||||||
scene.fog.color.setRGB(c0[0] * 0.05, c0[1] * 0.05, c0[2] * 0.07);
|
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
|
||||||
|
if (mid) scene.fog.color.copy(mid);
|
||||||
|
else scene.fog.color.setRGB(c0[0] * 0.05, c0[1] * 0.05, c0[2] * 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- vehicle body (built once; arrangement re-hangs it) ---
|
// --- vehicle body (built once; arrangement re-hangs it) ---
|
||||||
@@ -274,33 +478,43 @@ export const ride = {
|
|||||||
roughness: 0.4, metalness: 0.6,
|
roughness: 0.4, metalness: 0.6,
|
||||||
}));
|
}));
|
||||||
wheel.add(tire); wheel.add(hub);
|
wheel.add(tire); wheel.add(hub);
|
||||||
|
// Torus lies in the XY plane (axle along z) — rotate 90° about
|
||||||
|
// Y so the axle runs across the car (x) and the tire rolls
|
||||||
|
// along z. Spin is then rotation.x on the group... which the
|
||||||
|
// yaw would fight — so spin the tire/hub meshes, not the group.
|
||||||
|
wheel.rotation.y = Math.PI / 2;
|
||||||
wheel.position.set(wx, -0.72, wz);
|
wheel.position.set(wx, -0.72, wz);
|
||||||
|
wheel.userData.tire = tire;
|
||||||
|
wheel.userData.hub = hub;
|
||||||
instance.car.add(wheel);
|
instance.car.add(wheel);
|
||||||
instance.bodyMeshes.push(wheel);
|
instance.bodyMeshes.push(wheel);
|
||||||
instance.wheels.push(wheel);
|
instance.wheels.push(wheel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Radio: the viz screen. Rises above the dash on a small stalk so
|
// Radio: the viz screen, on the dash ahead of the crew, facing BACK
|
||||||
// it reads over the bodywork from chase angles — emissiveMap bound
|
// at the passengers (+z face toward the cabin — a screen is for
|
||||||
// per-frame by bindVizTexture, emissive stays near-white so the
|
// watching, and the audience that can see it rides inside). The car
|
||||||
// viz keeps its own colours.
|
// drives toward -z, so the radio sits forward (z < crew) with its
|
||||||
|
// face rearward. A small dash unit, NOT a billboard. EmissiveMap
|
||||||
|
// bound per-frame by bindVizTexture, emissive stays near-white so
|
||||||
|
// the viz keeps its own colours. From a front chase angle it reads
|
||||||
|
// dark — correct, a dash screen is not for oncoming traffic.
|
||||||
const radioMat = new T.MeshStandardMaterial({
|
const radioMat = new T.MeshStandardMaterial({
|
||||||
color: new T.Color(0.02, 0.02, 0.02),
|
color: new T.Color(0.02, 0.02, 0.02),
|
||||||
emissive: new T.Color(1, 1, 1),
|
emissive: new T.Color(1, 1, 1),
|
||||||
emissiveIntensity: 1.2,
|
emissiveIntensity: 1.4,
|
||||||
roughness: 0.5, metalness: 0.2,
|
roughness: 0.5, metalness: 0.2,
|
||||||
side: T.DoubleSide,
|
|
||||||
});
|
});
|
||||||
const stalk = new T.Mesh(
|
const dash = new T.Mesh(
|
||||||
new T.CylinderGeometry(0.05, 0.07, 0.5, 8),
|
new T.BoxGeometry(0.85, 0.3, 0.28),
|
||||||
new T.MeshStandardMaterial({ color: new T.Color(0.05, 0.05, 0.06), roughness: 0.6 }));
|
new T.MeshStandardMaterial({ color: new T.Color(0.05, 0.05, 0.06), roughness: 0.6 }));
|
||||||
stalk.position.set(0, 0.75, 0.9);
|
dash.position.set(0, 0.72, -0.95);
|
||||||
instance.car.add(stalk);
|
instance.car.add(dash);
|
||||||
instance.bodyMeshes.push(stalk);
|
instance.bodyMeshes.push(dash);
|
||||||
const radio = new T.Mesh(new T.PlaneGeometry(1.3, 0.73), radioMat);
|
const radio = new T.Mesh(new T.PlaneGeometry(0.62, 0.35), radioMat);
|
||||||
radio.position.set(0, 1.15, 0.9);
|
radio.position.set(0, 0.82, -0.8);
|
||||||
radio.rotation.x = -0.12;
|
radio.rotation.x = 0.15;
|
||||||
radio.rotation.y = Math.PI;
|
radio.rotation.y = 0;
|
||||||
radio.name = 'radio';
|
radio.name = 'radio';
|
||||||
instance.car.add(radio);
|
instance.car.add(radio);
|
||||||
instance.radioMat = radioMat;
|
instance.radioMat = radioMat;
|
||||||
@@ -316,7 +530,10 @@ export const ride = {
|
|||||||
for (const bx of [-0.55, 0.55]) {
|
for (const bx of [-0.55, 0.55]) {
|
||||||
const beamGeo = new T.ConeGeometry(0.45, 3.5, 12, 1, true);
|
const beamGeo = new T.ConeGeometry(0.45, 3.5, 12, 1, true);
|
||||||
beamGeo.translate(0, -1.75, 0);
|
beamGeo.translate(0, -1.75, 0);
|
||||||
beamGeo.rotateX(-Math.PI / 2 - 0.06);
|
// Tip at origin, base forward (-z, direction of travel):
|
||||||
|
// rotateX(+PI/2) maps -y to -z. The old -PI/2 aimed them
|
||||||
|
// rearward over the cabin, 180° off.
|
||||||
|
beamGeo.rotateX(Math.PI / 2 - 0.06);
|
||||||
const beam = new T.Mesh(beamGeo, beamMat);
|
const beam = new T.Mesh(beamGeo, beamMat);
|
||||||
beam.position.set(bx, -0.1, -2.2);
|
beam.position.set(bx, -0.1, -2.2);
|
||||||
instance.car.add(beam);
|
instance.car.add(beam);
|
||||||
@@ -373,43 +590,155 @@ export const ride = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- motion: analytic, no integration ---
|
// --- motion: analytic, no integration ---
|
||||||
const speed = (params.speed || 0.7) * (instance.chase ? 1 : 0);
|
// Drive speed: the sampled param floored so even calm songs roll, then
|
||||||
const rate = 1 + (features.bandLow || 0) * 0.6;
|
// scaled by section energy (venueState is interpolated, so this swells
|
||||||
// Road scroll in chase mode: dashes stream past; parked otherwise.
|
// into drops rather than stepping). Parked mode rolls at ~zero.
|
||||||
if (instance.chase && speed > 0.01) {
|
// Deliberately NOT modulated by instantaneous bands: multiplying the
|
||||||
const dz = speed * 9 * rate;
|
// scroll clock by bandLow fast-forwards the world on every loud hit
|
||||||
for (const d of instance.dashes.children) {
|
// and rewinds it in quiet moments — the car surged back and forth
|
||||||
let z = d.position.z + dz * 0.016;
|
// with the volume. Speed-up/slow-down lives in the smooth energy arc.
|
||||||
if (z > 4) z -= 72;
|
const energyK = vState ? (0.55 + vState.light * 0.65) : 1;
|
||||||
d.position.z = z;
|
const speed = (0.35 + (params.speed || 0.7) * 0.75) * energyK * (instance.chase ? 1 : 0);
|
||||||
|
const rate = 1;
|
||||||
|
// World scroll: dashes, posts, roadside, chevrons and streaks stream
|
||||||
|
// past toward +z (the car drives toward -z, so the world comes at the
|
||||||
|
// camera). Parked mode keeps a 12% idle creep plus a floor so the
|
||||||
|
// world never reads dead — a frozen world with a crawling camera
|
||||||
|
// measured as "no movement at all". Analytic in t (not += per frame):
|
||||||
|
// a seek must land on the same positions as playback. The chase rate
|
||||||
|
// reads as driving at a glance (~3x the original).
|
||||||
|
{
|
||||||
|
const dz = (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate;
|
||||||
|
const kids = instance.dashes.children;
|
||||||
|
for (let i = 0; i < kids.length; i++) {
|
||||||
|
const z0 = -70 + i * 6;
|
||||||
|
const span = 76;
|
||||||
|
const z = z0 + t * dz;
|
||||||
|
kids[i].position.z = z0 + (((z - z0) % span) + span) % span;
|
||||||
|
}
|
||||||
|
const pk = instance.posts.children;
|
||||||
|
for (let i = 0; i < pk.length; i++) {
|
||||||
|
const z0 = -78 + i * 6;
|
||||||
|
const span = 84;
|
||||||
|
const z = z0 + t * dz;
|
||||||
|
pk[i].position.z = z0 + (((z - z0) % span) + span) % span;
|
||||||
|
}
|
||||||
|
// Roadside set dressing streams on the same clock: 12 slots spaced
|
||||||
|
// 8 over [-84, +12]. Same wrap math, so houses/signs/trees and the
|
||||||
|
// billboard hold formation with the posts.
|
||||||
|
const rs = instance.roadside.children;
|
||||||
|
for (let i = 0; i < rs.length; i++) {
|
||||||
|
const z0 = -84 + i * 8;
|
||||||
|
const span = 96;
|
||||||
|
const z = z0 + t * dz;
|
||||||
|
rs[i].position.z = z0 + (((z - z0) % span) + span) % span;
|
||||||
|
}
|
||||||
|
// Chevrons ride the same clock, wider spacing.
|
||||||
|
const ch = instance.chevrons.children;
|
||||||
|
for (let i = 0; i < ch.length; i++) {
|
||||||
|
const z0 = -72 + i * 10;
|
||||||
|
const span = 80;
|
||||||
|
const z = z0 + t * dz;
|
||||||
|
ch[i].position.z = z0 + (((z - z0) % span) + span) % span;
|
||||||
|
}
|
||||||
|
// Wind streaks rush past at ~1.6x road speed, hugging the frame
|
||||||
|
// edges; faint in parked mode, strong at speed.
|
||||||
|
for (let i = 0; i < instance.streaks.children.length; i++) {
|
||||||
|
const s = instance.streaks.children[i];
|
||||||
|
const z0 = -60 + i * 7;
|
||||||
|
const span = 70;
|
||||||
|
const z = z0 + t * dz * 1.6;
|
||||||
|
s.position.z = z0 + (((z - z0) % span) + span) % span;
|
||||||
|
s.position.x = s.userData.baseX + Math.sin(t * 0.7 + instance.streakSeeds[i]) * 0.4;
|
||||||
|
// Face the camera: yaw-only billboard around the streak's long
|
||||||
|
// (y) axis toward the camera position.
|
||||||
|
const dx = camera.position.x - s.position.x;
|
||||||
|
const dzz = camera.position.z - s.position.z;
|
||||||
|
s.rotation.y = Math.atan2(dx, dzz);
|
||||||
|
}
|
||||||
|
instance.streakMat.opacity = instance.chase
|
||||||
|
? 0.05 + Math.min(0.2, speed * 0.12)
|
||||||
|
: 0.04;
|
||||||
|
}
|
||||||
|
// Suspension + weave: the car floats on its springs (two detuned sines
|
||||||
|
// so it never loops visibly), leans into a slow lane-change sweep, and
|
||||||
|
// shudders with section energy — never the instantaneous band, which
|
||||||
|
// jerks the body on every hit like the old scroll clock did. All
|
||||||
|
// analytic f(t): weave period ~47 s so a song holds two crossings;
|
||||||
|
// parked mode nearly still.
|
||||||
|
const shudder = vState ? (0.5 + vState.light * 0.7) : 1;
|
||||||
|
const bob = (Math.sin(t * 2.1) * 0.5 + Math.sin(t * 3.7 + 1.3) * 0.5)
|
||||||
|
* 0.045 * shudder
|
||||||
|
// Idle engine shake: parked cars shiver faintly at ~9 Hz so a
|
||||||
|
// stationary frame never sits pixel-dead.
|
||||||
|
+ (instance.chase ? 0 : Math.sin(t * 9.3) * 0.006);
|
||||||
|
const sweepAmp = instance.chase ? 1.1 : 0.15;
|
||||||
|
const sweep = Math.sin(t * 0.133 + instance.seed * 0.001);
|
||||||
|
instance.car.position.x = sweep * sweepAmp;
|
||||||
|
instance.car.position.y = bob * (instance.chase ? 1 : 0.3);
|
||||||
|
// Lean INTO the sweep (roll) and yaw with it, like a lane change.
|
||||||
|
const leanK = instance.chase ? 1 : 0.2;
|
||||||
|
instance.car.rotation.z = -Math.cos(t * 0.133 + instance.seed * 0.001) * 0.035 * leanK
|
||||||
|
+ Math.sin(t * 0.9) * 0.008 * leanK;
|
||||||
|
instance.car.rotation.y = Math.cos(t * 0.133 + instance.seed * 0.001) * 0.045 * (instance.chase ? 1 : 0.2);
|
||||||
|
instance.car.rotation.x = Math.sin(t * 1.7 + 0.6) * 0.012 * (instance.chase ? 1 : 0.3);
|
||||||
|
if (instance.wheels) {
|
||||||
|
// Rolling spin on the meshes (local axes), not the yawed group:
|
||||||
|
// tire about its hole axis (local z), hub about its cylinder
|
||||||
|
// axis (local x after the geometry pre-rotation).
|
||||||
|
for (const w of instance.wheels) {
|
||||||
|
if (w.userData.tire) w.userData.tire.rotation.z = t * speed * 6 * rate;
|
||||||
|
if (w.userData.hub) w.userData.hub.rotation.x = t * speed * 6 * rate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Wheels spin with speed; bob with the low band.
|
|
||||||
const bob = Math.sin(t * 2.1) * 0.02 * (0.4 + (features.bandLow || 0));
|
|
||||||
instance.car.position.y = bob * (instance.chase ? 1 : 0.3);
|
|
||||||
instance.car.rotation.z = Math.sin(t * 0.9) * 0.008 * (instance.chase ? 1 : 0.3);
|
|
||||||
if (instance.wheels) {
|
|
||||||
for (const w of instance.wheels) w.rotation.x = t * speed * 6 * rate;
|
|
||||||
}
|
|
||||||
// Passengers bob gently, out of phase — analytic f(t), seek-stable.
|
// Passengers bob gently, out of phase — analytic f(t), seek-stable.
|
||||||
// popMul gates how many are visible (setup: driver + 1; climax: full car).
|
// popMul gates how many ride (setup: driver + 1; climax: full car) as
|
||||||
|
// a SMOOTH opacity fade, never a visible toggle: the interpolated
|
||||||
|
// venueState crosses thresholds at arbitrary mid-section moments, and
|
||||||
|
// hard cuts there read as random flashes. Pure f(threshold, index).
|
||||||
const popMul = vState ? vState.popMul : 1;
|
const popMul = vState ? vState.popMul : 1;
|
||||||
|
const crewLevel = 0.35 + popMul * 0.75;
|
||||||
for (const g of instance.crew.children) {
|
for (const g of instance.crew.children) {
|
||||||
const idx = g.userData.index || 0;
|
const idx = g.userData.index || 0;
|
||||||
g.position.y = (g.userData.baseY || 0) + Math.sin(t * 0.5 + (g.userData.phase || 0)) * 0.03;
|
g.position.y = (g.userData.baseY || 0) + Math.sin(t * 0.5 + (g.userData.phase || 0)) * 0.03;
|
||||||
g.rotation.y = Math.PI + Math.sin(t * 0.18 + idx * 1.3) * 0.22;
|
g.rotation.y = Math.PI + Math.sin(t * 0.18 + idx * 1.3) * 0.22;
|
||||||
const showIndex = idx / Math.max(1, instance.popCount);
|
const showIndex = idx / Math.max(1, instance.popCount);
|
||||||
g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75));
|
const fade = Math.max(0, Math.min(1, (crewLevel - showIndex) / 0.12));
|
||||||
|
g.visible = opacity > 0.01 && fade > 0.01;
|
||||||
for (const mesh of g.children) {
|
for (const mesh of g.children) {
|
||||||
if (mesh.isMesh) {
|
if (mesh.isMesh) {
|
||||||
mesh.material.opacity = opacity;
|
mesh.material.opacity = opacity * fade;
|
||||||
mesh.material.transparent = opacity < 0.999;
|
mesh.material.transparent = opacity * fade < 0.999;
|
||||||
if (vState && mesh.material.emissiveIntensity !== undefined) {
|
if (vState && mesh.material.emissiveIntensity !== undefined) {
|
||||||
mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0;
|
mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Roadside density is fixed per arrangement (re-hung only on story
|
||||||
|
// jumps, under a camera cut) — gating it by the interpolated popMul
|
||||||
|
// toggled houses mid-section at arbitrary moments, which reads as
|
||||||
|
// random flashes. Sparse arrangements show sentinels, dense ones the
|
||||||
|
// whole town; light/fog still arc within each.
|
||||||
|
if (instance.roadside) {
|
||||||
|
const n = instance.roadside.children.length;
|
||||||
|
const dense = (arrangement % 2 === 1) || (vState && vState.popMul > 0.8);
|
||||||
|
const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
instance.roadside.children[i].visible = opacity > 0.01 && (i < showUpTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dressing takes the palette tint so houses/trees belong to the song;
|
||||||
|
// the billboard glows with the section light (its IMAGE is the viz).
|
||||||
|
if (instance.dressMat) {
|
||||||
|
const tintM = 0.5 + fieldLight * 0.7;
|
||||||
|
instance.dressMat.color.setRGB(c0[0] * 0.14 * tintM + 0.03, c0[1] * 0.14 * tintM + 0.03, c0[2] * 0.16 * tintM + 0.04);
|
||||||
|
}
|
||||||
|
if (instance.billMat) {
|
||||||
|
const l = vState ? vState.light : 1;
|
||||||
|
instance.billMat.emissive.setRGB(1, 1, 1);
|
||||||
|
instance.billMat.emissiveIntensity = (params.light || 0.85) * (0.7 + l * 0.7);
|
||||||
|
}
|
||||||
// Radio glow follows the section light; the IMAGE is the viz texture.
|
// Radio glow follows the section light; the IMAGE is the viz texture.
|
||||||
if (instance.radioMat) {
|
if (instance.radioMat) {
|
||||||
const l = vState ? vState.light : 1;
|
const l = vState ? vState.light : 1;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Shared sky dome for model venues: a gradient backdrop derived from the
|
||||||
|
// song, replacing the clear-to-black background every venue used to sit on.
|
||||||
|
//
|
||||||
|
// Why shared: the background is the largest single region in every venue
|
||||||
|
// frame, and three hand-rolled gradients would drift into three art styles.
|
||||||
|
// One helper, one grammar — hue from the palette, level from the section,
|
||||||
|
// texture from the personality — and each venue reads as the same video in a
|
||||||
|
// different place.
|
||||||
|
//
|
||||||
|
// Contract (mirrors the venue module shape, but it is NOT a venue):
|
||||||
|
// buildSky(THREE, { radius }) -> { mesh, mat } // BackSide sphere shell
|
||||||
|
// paintSky(mat, { palette, light, style, seed }) // per-frame recolour
|
||||||
|
//
|
||||||
|
// Determinism: pure f(inputs), no rng held, no wall-clock. The dome is static
|
||||||
|
// geometry; only its colours move, and they move with venueState.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the dome mesh. Radius must exceed the venue's far plane content but
|
||||||
|
* stay inside the camera far (200) and the fog wash — fog applies, which is
|
||||||
|
* what melts the horizon into the ground.
|
||||||
|
*/
|
||||||
|
export function buildSky(THREE, { radius = 90 } = {}) {
|
||||||
|
const T = THREE;
|
||||||
|
const mat = new T.ShaderMaterial({
|
||||||
|
side: T.BackSide,
|
||||||
|
depthWrite: false,
|
||||||
|
fog: false,
|
||||||
|
uniforms: {
|
||||||
|
u_top: { value: new T.Color(0.05, 0.06, 0.10) },
|
||||||
|
u_mid: { value: new T.Color(0.10, 0.08, 0.14) },
|
||||||
|
u_bot: { value: new T.Color(0.02, 0.02, 0.03) },
|
||||||
|
u_bands: { value: 0.0 },
|
||||||
|
u_seed: { value: 0.0 },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec3 vPos;
|
||||||
|
void main() {
|
||||||
|
vPos = position;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 u_top;
|
||||||
|
uniform vec3 u_mid;
|
||||||
|
uniform vec3 u_bot;
|
||||||
|
uniform float u_bands;
|
||||||
|
uniform float u_seed;
|
||||||
|
varying vec3 vPos;
|
||||||
|
void main() {
|
||||||
|
float h = normalize(vPos).y; // -1 .. 1
|
||||||
|
vec3 col = h > 0.0
|
||||||
|
? mix(u_mid, u_top, pow(h, 0.65))
|
||||||
|
: mix(u_mid, u_bot, pow(clamp(-h * 1.6, 0.0, 1.0), 0.8));
|
||||||
|
// Faint horizontal banding (strata / city glow lines) when the
|
||||||
|
// personality asks for texture; silent otherwise.
|
||||||
|
if (u_bands > 0.001) {
|
||||||
|
float b = sin(h * 40.0 + u_seed) * 0.5 + 0.5;
|
||||||
|
b = smoothstep(0.75, 0.95, b) * u_bands;
|
||||||
|
col += u_top * b * 0.6;
|
||||||
|
}
|
||||||
|
gl_FragColor = vec4(col, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
const mesh = new T.Mesh(new T.SphereGeometry(radius, 24, 16), mat);
|
||||||
|
mesh.name = 'sky';
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
return { mesh, mat };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recolour the dome from the song: hue from the palette, level from the
|
||||||
|
* section light, texture from the personality style. All continuous inputs —
|
||||||
|
* nothing here may step at a boundary.
|
||||||
|
*/
|
||||||
|
export function paintSky(mat, { palette, light = 0.7, style = null, seed = 0 } = {}) {
|
||||||
|
if (!mat) return;
|
||||||
|
const pal = palette && palette.length ? palette : [[0.5, 0.5, 0.6], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
|
||||||
|
const u = mat.uniforms;
|
||||||
|
// Zenith: palette's coolest entry deepened; mid: warmest entry at dusk
|
||||||
|
// level; nadir: near-black of the first entry (melts into the ground).
|
||||||
|
const top = pal[2] || pal[0];
|
||||||
|
const mid = pal[1] || pal[0];
|
||||||
|
const bot = pal[0];
|
||||||
|
const lv = 0.35 + light * 0.85;
|
||||||
|
u.u_top.value.setRGB(top[0] * 0.55 * lv, top[1] * 0.55 * lv, top[2] * 0.75 * lv);
|
||||||
|
u.u_mid.value.setRGB(mid[0] * 0.5 * lv, mid[1] * 0.42 * lv, mid[2] * 0.5 * lv);
|
||||||
|
u.u_bot.value.setRGB(bot[0] * 0.12, bot[1] * 0.12, bot[2] * 0.15);
|
||||||
|
// Texture: grain-handed tracks get strata bands, clean ones a flat grade.
|
||||||
|
const tex = style && typeof style.texture === 'number' ? style.texture : 0.5;
|
||||||
|
u.u_bands.value = Math.max(0, Math.min(1, (tex - 0.45) * 2.2));
|
||||||
|
u.u_seed.value = (seed % 1000) / 1000;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// from actorToGeometry primitives via stageNode. One venue = one place.
|
// from actorToGeometry primitives via stageNode. One venue = one place.
|
||||||
|
|
||||||
import { actorToGeometry, castShape } from '../actors/meshes.js';
|
import { actorToGeometry, castShape } from '../actors/meshes.js';
|
||||||
|
import { buildSky, paintSky } from './sky.js';
|
||||||
|
|
||||||
export const stage = {
|
export const stage = {
|
||||||
name: 'Stage',
|
name: 'Stage',
|
||||||
@@ -97,12 +98,17 @@ export const stage = {
|
|||||||
scene.fog = new T.Fog(0x0a0a0f, 10, 26);
|
scene.fog = new T.Fog(0x0a0a0f, 10, 26);
|
||||||
scene.userData.transparentBackground = true;
|
scene.userData.transparentBackground = true;
|
||||||
|
|
||||||
|
// Song sky over the open field — radius 90 clears the 44-unit ground.
|
||||||
|
const sky = buildSky(T, { radius: 90 });
|
||||||
|
scene.add(sky.mesh);
|
||||||
|
|
||||||
const pylonContainer = new T.Group();
|
const pylonContainer = new T.Group();
|
||||||
pylonContainer.name = 'pylons';
|
pylonContainer.name = 'pylons';
|
||||||
scene.add(pylonContainer);
|
scene.add(pylonContainer);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ground, groundMat, screen, screenMat, ambient, key, fill,
|
ground, groundMat, screen, screenMat, ambient, key, fill,
|
||||||
|
skyMesh: sky.mesh, skyMat: sky.mat,
|
||||||
pylonContainer,
|
pylonContainer,
|
||||||
seed,
|
seed,
|
||||||
venueSpec: venueSpec || null,
|
venueSpec: venueSpec || null,
|
||||||
@@ -166,11 +172,18 @@ export const stage = {
|
|||||||
// Ground level follows horizon
|
// Ground level follows horizon
|
||||||
const groundY = -1.15 + (spaceHorizon - 0.5) * 0.6;
|
const groundY = -1.15 + (spaceHorizon - 0.5) * 0.6;
|
||||||
instance.ground.position.y = groundY;
|
instance.ground.position.y = groundY;
|
||||||
|
// Song sky + matched fog, same contract as chamber.
|
||||||
|
paintSky(instance.skyMat, {
|
||||||
|
palette: pal, light: fieldLight, style,
|
||||||
|
seed: instance.seed,
|
||||||
|
});
|
||||||
if (scene.fog) {
|
if (scene.fog) {
|
||||||
const fogMul = vState ? vState.fog : 0.35; const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
|
const fogMul = vState ? vState.fog : 0.35; const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
|
||||||
scene.fog.near = fogDepth;
|
scene.fog.near = fogDepth;
|
||||||
scene.fog.far = fogDepth + 18;
|
scene.fog.far = fogDepth + 18;
|
||||||
scene.fog.color.setRGB(c0[0]*0.07, c0[1]*0.07, c0[2]*0.09);
|
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
|
||||||
|
if (mid) scene.fog.color.copy(mid);
|
||||||
|
else scene.fog.color.setRGB(c0[0]*0.07, c0[1]*0.07, c0[2]*0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Column count is locked at first build: it used to re-derive from live
|
// Column count is locked at first build: it used to re-derive from live
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ import { scenes } from '/src/scenes/registry.js';
|
|||||||
import { blit } from '/src/checks/blit.js';
|
import { blit } from '/src/checks/blit.js';
|
||||||
|
|
||||||
const THUMB = { width: 320, height: 180 };
|
const THUMB = { width: 320, height: 180 };
|
||||||
const DEFAULT_ROWS = 5;
|
const DEFAULT_ROWS = 3;
|
||||||
const DEFAULT_COLS = 5;
|
const DEFAULT_COLS = 3;
|
||||||
const ROW_OPTIONS = [2, 3, 4, 5, 6, 8, 10];
|
const ROW_OPTIONS = [2, 3, 4, 5, 6, 8, 10];
|
||||||
const COL_OPTIONS = [1, 2, 3, 4, 5, 6, 8];
|
const COL_OPTIONS = [1, 2, 3, 4, 5, 6, 8];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user