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:
Dejvino
2026-09-18 18:57:04 +02:00
parent 46fab512df
commit 9c768f13d1
10 changed files with 604 additions and 60 deletions
+369 -40
View File
@@ -10,6 +10,7 @@
// ArcDriver (never stepped).
import { actorToGeometry } from '../actors/meshes.js';
import { buildSky, paintSky } from './sky.js';
export const ride = {
name: 'Ride',
@@ -35,13 +36,18 @@ export const ride = {
// Venue RTT: the compositor calls this with the finished viz texture after
// 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) {
if (!instance || !texture) return;
if (instance.radioMat && instance.radioMat.emissiveMap !== texture) {
instance.radioMat.emissiveMap = texture;
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.
@@ -88,6 +94,73 @@ export const ride = {
}
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
// road even when the asphalt is near-black.
const edgeMat = new T.MeshStandardMaterial({
@@ -106,6 +179,122 @@ export const ride = {
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
// start). Body panels come from the track's actor; chassis/cabin/wheels
// 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.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.
const crew = new T.Group();
crew.name = 'crew';
car.add(crew);
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,
radioMat: null, radioMesh: null,
radioMat: null, radioMesh: null, billPanel: null,
bodyMeshes: [],
seed,
venueSpec: venueSpec || null,
@@ -182,12 +379,19 @@ export const ride = {
if (instance.key) instance.key.intensity = 0.7 + fieldLight * 1.1;
if (instance.ambient) instance.ambient.intensity = 0.4 + fieldLight * 0.5;
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) {
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.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) ---
@@ -274,33 +478,43 @@ export const ride = {
roughness: 0.4, metalness: 0.6,
}));
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.userData.tire = tire;
wheel.userData.hub = hub;
instance.car.add(wheel);
instance.bodyMeshes.push(wheel);
instance.wheels.push(wheel);
}
// Radio: the viz screen. Rises above the dash on a small stalk so
// it reads over the bodywork from chase angles — emissiveMap bound
// per-frame by bindVizTexture, emissive stays near-white so the
// viz keeps its own colours.
// Radio: the viz screen, on the dash ahead of the crew, facing BACK
// at the passengers (+z face toward the cabin — a screen is for
// watching, and the audience that can see it rides inside). The car
// 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({
color: new T.Color(0.02, 0.02, 0.02),
emissive: new T.Color(1, 1, 1),
emissiveIntensity: 1.2,
emissiveIntensity: 1.4,
roughness: 0.5, metalness: 0.2,
side: T.DoubleSide,
});
const stalk = new T.Mesh(
new T.CylinderGeometry(0.05, 0.07, 0.5, 8),
const dash = new T.Mesh(
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 }));
stalk.position.set(0, 0.75, 0.9);
instance.car.add(stalk);
instance.bodyMeshes.push(stalk);
const radio = new T.Mesh(new T.PlaneGeometry(1.3, 0.73), radioMat);
radio.position.set(0, 1.15, 0.9);
radio.rotation.x = -0.12;
radio.rotation.y = Math.PI;
dash.position.set(0, 0.72, -0.95);
instance.car.add(dash);
instance.bodyMeshes.push(dash);
const radio = new T.Mesh(new T.PlaneGeometry(0.62, 0.35), radioMat);
radio.position.set(0, 0.82, -0.8);
radio.rotation.x = 0.15;
radio.rotation.y = 0;
radio.name = 'radio';
instance.car.add(radio);
instance.radioMat = radioMat;
@@ -316,7 +530,10 @@ export const ride = {
for (const bx of [-0.55, 0.55]) {
const beamGeo = new T.ConeGeometry(0.45, 3.5, 12, 1, true);
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);
beam.position.set(bx, -0.1, -2.2);
instance.car.add(beam);
@@ -373,43 +590,155 @@ export const ride = {
}
// --- motion: analytic, no integration ---
const speed = (params.speed || 0.7) * (instance.chase ? 1 : 0);
const rate = 1 + (features.bandLow || 0) * 0.6;
// Road scroll in chase mode: dashes stream past; parked otherwise.
if (instance.chase && speed > 0.01) {
const dz = speed * 9 * rate;
for (const d of instance.dashes.children) {
let z = d.position.z + dz * 0.016;
if (z > 4) z -= 72;
d.position.z = z;
// Drive speed: the sampled param floored so even calm songs roll, then
// scaled by section energy (venueState is interpolated, so this swells
// into drops rather than stepping). Parked mode rolls at ~zero.
// Deliberately NOT modulated by instantaneous bands: multiplying the
// scroll clock by bandLow fast-forwards the world on every loud hit
// and rewinds it in quiet moments — the car surged back and forth
// with the volume. Speed-up/slow-down lives in the smooth energy arc.
const energyK = vState ? (0.55 + vState.light * 0.65) : 1;
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.
// 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 crewLevel = 0.35 + popMul * 0.75;
for (const g of instance.crew.children) {
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.rotation.y = Math.PI + Math.sin(t * 0.18 + idx * 1.3) * 0.22;
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) {
if (mesh.isMesh) {
mesh.material.opacity = opacity;
mesh.material.transparent = opacity < 0.999;
mesh.material.opacity = opacity * fade;
mesh.material.transparent = opacity * fade < 0.999;
if (vState && mesh.material.emissiveIntensity !== undefined) {
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.
if (instance.radioMat) {
const l = vState ? vState.light : 1;