Ride: roadside actor figures among the props on some songs

Two seeded slots on ~40% of builds hold figures of the track's own cast, grown on first update like the crew (identity arrives via update), normalized to person-scale with feet planted via measured bounds, palette-recolored per frame, glancing around analytically. Static for the whole video: they stream with their slots and never pop. Verified variance across seeds, pose and planting.
This commit is contained in:
Dejvino
2026-09-21 20:17:59 +02:00
parent 215fe98c06
commit 18f1295984
+107 -6
View File
@@ -284,15 +284,28 @@ export const ride = {
roadside.name = 'roadside'; roadside.name = 'roadside';
// 12 slots spaced 8 over [-84, +4]: house / sign / trees / billboard. // 12 slots spaced 8 over [-84, +4]: house / sign / trees / billboard.
// Billboard appears at most once, on one side, placed mid-span so it // Billboard appears at most once, on one side, placed mid-span so it
// is on screen for a stretch, not a flash. // is on screen for a stretch, not a flash. On ~40% of builds two
// slots instead hold actor figures — the track's own characters
// standing among the props (never the billboard slot). Static for
// the whole video: figures never pop in mid-shot, they stream past
// with their slot like every other prop.
const billSlot = 3 + Math.floor(rng() * 6); const billSlot = 3 + Math.floor(rng() * 6);
const billSide = rng() < 0.5 ? -1 : 1; const billSide = rng() < 0.5 ? -1 : 1;
const roadsideActors = rng() < 0.4;
const actorSlots = new Set();
if (roadsideActors) {
while (actorSlots.size < 2) {
const s = Math.floor(rng() * 12);
if (s !== billSlot) actorSlots.add(s);
}
}
const roadsideMeta = []; const roadsideMeta = [];
const roadsidePopulation = 12; const roadsidePopulation = 12;
for (let i = 0; i < roadsidePopulation; i++) { for (let i = 0; i < roadsidePopulation; i++) {
const g = new T.Group(); const g = new T.Group();
const side = i % 2 === 0 ? -1 : 1; const side = i % 2 === 0 ? -1 : 1;
const kind = i === billSlot ? 'billboard' const kind = i === billSlot ? 'billboard'
: actorSlots.has(i) ? 'actor'
: i % 3 === 0 ? 'house' : i % 3 === 1 ? 'sign' : 'trees'; : i % 3 === 0 ? 'house' : i % 3 === 1 ? 'sign' : 'trees';
// Dressing lives far out (houses ±13-18, signs/trees ±9-13): near // 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 // items cross the frame in a blink, far items linger and let the
@@ -330,8 +343,7 @@ export const ride = {
diamond.rotation.z = Math.PI / 4; diamond.rotation.z = Math.PI / 4;
diamond.rotation.y = side > 0 ? -0.35 : 0.35; diamond.rotation.y = side > 0 ? -0.35 : 0.35;
g.add(diamond); g.add(diamond);
} else if (kind === 'billboard') { } else if (kind === 'billboard') { // Two posts + big panel carrying the viz (bound in
// Two posts + big panel carrying the viz (bound in
// bindVizTexture alongside the radio). Faces the road. // bindVizTexture alongside the radio). Faces the road.
const pGeo = new T.CylinderGeometry(0.12, 0.12, 3.4, 6); const pGeo = new T.CylinderGeometry(0.12, 0.12, 3.4, 6);
for (const px of [-1.4, 1.4]) { for (const px of [-1.4, 1.4]) {
@@ -349,6 +361,13 @@ export const ride = {
panel.name = 'billboard'; panel.name = 'billboard';
g.add(panel); g.add(panel);
g.userData.billPanel = panel; g.userData.billPanel = panel;
} else if (kind === 'actor') {
// A figure of the track's own cast, grown on first update
// when identity arrives (same pattern as the crew): until
// then the slot is an empty group, which never renders.
g.userData.actorSlot = true;
g.userData.actorBuilt = false;
g.userData.phase = rng() * Math.PI * 2;
} else { } else {
// Trees: 2-3 cones on cylinders, dark green glow. // Trees: 2-3 cones on cylinders, dark green glow.
const n = 2 + Math.floor(rng() * 2); const n = 2 + Math.floor(rng() * 2);
@@ -542,9 +561,27 @@ export const ride = {
// — the chassis above varies per track, this is what makes every // — the chassis above varies per track, this is what makes every
// reading of it a convertible rather than a tractor. Painted // reading of it a convertible rather than a tractor. Painted
// panels join the viz reflection feed (more surface mirroring the // panels join the viz reflection feed (more surface mirroring the
// video); dark trim stays matte. All constants: identical on // video); dark trim stays matte.
// every track, every run. //
// Varied per track, not per run: a private rng fork (the main
// stream is untouched, so crew draws don't move) plus the track's
// shape and style. Discrete variants only ever swap whole parts
// that interpenetrate identically — counts never change mid-video
// (arrangement-static, cut-covered like everything structural).
{ {
const trimRng = mulberry(((instance.seed >>> 0) ^ 0x7e33d21b) >>> 0);
const pick = (n) => Math.floor(trimRng() * n);
// Rear wing: none, lip, or full blade.
const spoilerKind = trimRng() < 0.3 ? 'none' : trimRng() < 0.72 ? 'blade' : 'lip';
// Rally stripes in the accent colour, or bare paint.
const stripeKind = trimRng() < 0.4 ? 'none' : trimRng() < 0.7 ? 'center' : 'dual';
// Hood intake scoop xor the spine (they share the same turf).
const scoop = trimRng() < 0.45;
// Exhausts: dual or quad.
const quad = trimRng() < 0.4;
// Chrome slats follow line weight; tilt rakes the long panels.
const slatCount = 2 + Math.min(2, Math.floor(styleWeight * 3));
const rake = (shapeTilt / Math.PI - 0.5) * 0.06;
const paint = cc(0); const paint = cc(0);
const paintMat = () => vizEnvMaterial(new T.MeshStandardMaterial({ const paintMat = () => vizEnvMaterial(new T.MeshStandardMaterial({
color: new T.Color(paint[0] * 0.85, paint[1] * 0.85, paint[2] * 0.85), color: new T.Color(paint[0] * 0.85, paint[1] * 0.85, paint[2] * 0.85),
@@ -864,7 +901,7 @@ export const ride = {
transparent: opacity < 0.999, opacity, transparent: opacity < 0.999, opacity,
})); }));
m.userData.partIndex = src.userData.partIndex ?? 0; m.userData.partIndex = src.userData.partIndex ?? 0;
} else { } else {
const col = cc(i + 1); const col = cc(i + 1);
m = new T.Mesh( m = new T.Mesh(
new T.SphereGeometry(0.22, 12, 10), new T.SphereGeometry(0.22, 12, 10),
@@ -1074,6 +1111,70 @@ export const ride = {
instance.roadside.children[i].visible = opacity > 0.001 && (i < showUpTo || i === forced); instance.roadside.children[i].visible = opacity > 0.001 && (i < showUpTo || i === forced);
} }
} }
// Roadside actors: figures of the track's cast standing among the
// props on some builds (seeded at build; static all video, so they
// stream past with their slot and never pop). Grown here, on first
// update, because identity only arrives via update — the crew pattern.
// They turn slowly to feel alive; analytic f(t), seek-stable.
if (instance.roadside && identity && actorSpec && actorSpec.form) {
for (const g of instance.roadside.children) {
if (!g.userData.actorSlot) continue;
if (!g.userData.actorBuilt) {
try {
const built = actorToGeometry(actorSpec, identity, T);
const fig = new T.Group();
fig.name = 'figure';
while (built.children.length) {
const m = built.children[0];
built.remove(m);
const pIdx = m.userData.partIndex ?? 0;
const pPal = actorSpec.paletteMap
? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx;
const c = pal[pPal % pal.length] || pal[0];
m.material = new T.MeshStandardMaterial({
color: new T.Color(c[0], c[1], c[2]),
roughness: 0.5, metalness: 0.08,
transparent: opacity < 0.999, opacity,
});
fig.add(m);
}
// Feet on the ground, person-scale: normalize the form
// to ~1.6 units tall (houses run 2-4) and plant the
// lowest point at the roadside level regardless of
// the form's own extent or origin. Local box
// (unparented) over slot scale: exact.
if (fig.children.length) {
const _box = new T.Box3().setFromObject(fig);
const _h = _box.max.y - _box.min.y;
const _s = _h > 1e-6 ? 1.6 / _h : 1;
fig.scale.setScalar(_s);
fig.position.y = -1.15 / g.scale.x - _s * _box.min.y;
}
g.add(fig);
g.userData.actorFig = fig;
instance.needsCompile = true;
} catch { /* figure optional; the slot stays empty */ }
g.userData.actorBuilt = true;
}
const fig = g.userData.actorFig;
if (fig) {
// Glance around rather than spinning: a bystander
// watching the road, not a turntable. Analytic f(t).
fig.rotation.y = (g.userData.phase || 0) + Math.sin(t * 0.12) * 0.55;
for (const m of fig.children) {
if (!m.isMesh) continue;
const pIdx = m.userData.partIndex ?? 0;
const pPal = actorSpec.paletteMap
? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx;
const c = pal[pPal % pal.length] || pal[0];
m.material.color.setRGB(c[0], c[1], c[2]);
m.material.opacity = opacity;
m.material.transparent = opacity < 0.999;
m.visible = opacity > 0.01;
}
}
}
}
// Dressing takes the palette tint so houses/trees belong to the song; // Dressing takes the palette tint so houses/trees belong to the song;
// the billboard glows with the section light (its IMAGE is the viz). // the billboard glows with the section light (its IMAGE is the viz).
if (instance.dressMat) { if (instance.dressMat) {